diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1d2bd31 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..a75cdaa --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,18 @@ +name: Greetings + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + greeting: + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue_message: "Dependabot's first issue" \ No newline at end of file diff --git a/.gitignore b/.gitignore index d132a40..a3e2f22 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ data/rag/* # qrz db data/qrz.db + +# fileMonitor test file +bee.txt + +# .csv files +*.csv \ No newline at end of file diff --git a/README.md b/README.md index b3c9b95..18c920d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance your [Meshtastic](https://meshtastic.org/docs/introduction/) network experience with a variety of powerful tools and fun features, connectivity and utility through text-based message delivery. Whether you're looking to perform network tests, send messages, or even play games, [mesh_bot.py](mesh_bot.py) has you covered. +TLDR: [Getting Started](#getting-started) + ![Example Use](etc/pong-bot.jpg "Example Use") ## Key Features @@ -16,41 +18,62 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo ### Network Tools - **Build, Test Local Mesh**: Ping allow for message delivery testing with more realistic packets vs. telemetry - **Test Node Hardware**: `test` will send incremental sized data into the radio buffer for overall length of message testing +- **Network Monitoring**: Alert on noisy nodes, node locations, and best placment for relay nodes. ### Multi Radio/Node Support - **Simultaneous Monitoring**: Monitor up to nine networks at the same time. - **Flexible Messaging**: send mail and messages, between networks. ### Advanced Messaging Capabilities -- **Mail Messaging**: Leave messages for other devices, which are sent as DMs when the device is seen. +- **Mail Messaging**: Leave messages for other devices, which are sent as DMs when the device is seen. Send mail to nodes using `bbspost @nodeNumber #message` or `bbspost @nodeShortName #message`. - **Scheduler**: Schedule messages like weather updates or reminders for weekly VHF nets. -- **Store and Forward**: Replay messages with the `messages` command, and log messages locally to disk. -- **Send Mail**: Send mail to nodes using `bbspost @nodeNumber #message` or `bbspost @nodeShortName #message`. +- **Store and Forward**: Like voicemail, see messages missed with the `messages` command. Can also log messages locally to disk. - **BBS Linking**: Combine multiple bots to expand BBS reach. -- **E-Mail/SMS**: Send mesh-messages to E-Mail or SMS(Email) expanding visability. +- **E-Mail/SMS**: Send mesh-messages to E-Mail or SMS(Email) expanding visibility. - **New Node Hello**: Send a hello to any new node seen in text message. ### Interactive AI and Data Lookup -- **NOAA location Data**: Get localized weather(alerts), River Flow, and Tide information. Open-Meteo is used for wx only outside NOAA coverage. +- **NOAA/USGS location Data**: Get localized weather(alerts), Earthquake, River Flow, and Tide information. Open-Meteo is used for wx only outside NOAA coverage. - **Wiki Integration**: Look up data using Wikipedia results. - **Ollama LLM AI**: Interact with the [Ollama](https://github.com/ollama/ollama/tree/main/docs) LLM AI for advanced queries and responses. -- **Satalite Pass Info**: Get passes for satalite at your location. +- **Satellite Pass Info**: Get passes for satellite at your location. +- **GeoMeasuring**: HowFar from point to point using collected GPS packets on the bot to plot a course or space. Find Center of points for Fox&Hound direction finding. ### Proximity Alerts -- **Location-Based Alerts**: Get notified when members arrive back at a configured lat/long, perfect for remote locations like campsites. +- **Location-Based Alerts**: Get notified when members arrive back at a configured lat/long, perfect for remote locations like campsites, or put a geo-fence. You can also run a script or send a email. Another idea is to lower the cycle and use the bot as a 'king of the hill' or ๐Ÿงญgeocache game. You can also run a script to change a node config or turn on the lights๐Ÿšฅ, have it drop an alert.txt to send a message like "Hello Start the ๐Ÿ“ŠSurvey" - **High Flying Alerts**: Get notified when nodes with high altitude are seen on mesh +- **Voice/Command Triggers**: The following keywords can be used via voice (VOX) to trigger bot functions "Hey Chirpy!" + - Say "Hey Chirpy.." + - `joke`: Tells a joke + - `weather`: Returns local weather forecast + - `moon`: Returns moonrise/set and phase info + - `daylight`: Returns sunrise/sunset times + - `river`: Returns NOAA river flow info + - `tide`: Returns NOAA tide information + - `satellite`: Returns satellite pass info ### CheckList / Check In Out -- **Asset Tracking**: Maintain a list of node/asset checkin and checkout. Usefull for accountability of people, assets. Radio-Net, FEMA, Trailhead. +- **Asset Tracking**: Maintain a list of node/asset checkin and checkout. Useful foraccountability of people, assets. Radio-Net, FEMA, Trailhead. ### Fun and Games - **Built-in Games**: Enjoy games like DopeWars, Lemonade Stand, BlackJack, and VideoPoker. - **FCC ARRL QuizBot**: The exam question pool quiz-bot. - **Command-Based Gameplay**: Issue `games` to display help and start playing. +- **Telemetry Leaderboard**: Fun stats like lowest ๐Ÿชซ battery or coldest temp ๐Ÿฅถ + +#### QuizMaster +- **Interactive Group Quizzes**: The QuizMaster module allows admins to start and stop quiz games for groups. Players can join, leave, and answer questions directly via DM or channel. +- **Scoring and Leaderboards**: Players can check their scores and see the top performers with `q: score` and `q: top`. +- **Easy Participation**: Players answer questions by prefixing their answer with `q:`, e.g., `q: 42`. + +#### Survey Module +- **Custom Surveys**: Easily create and deploy custom surveys by editing JSON files in `data/survey`. Multiple surveys can be managed (e.g., `survey snow`). +- **User Feedback Collection**: Users can participate in surveys via DM, and responses are logged for later review. ### Radio Frequency Monitoring - **SNR RF Activity Alerts**: Monitor a radio frequency and get alerts when high SNR RF activity is detected. - **Hamlib Integration**: Use Hamlib (rigctld) to watch the S meter on a connected radio. +- **Speech to Text Brodcasting to Mesh** Using [vosk](https://alphacephei.com/vosk/models) to translate to text. ### EAS Alerts - **FEMA iPAWS/EAS Alerts via API**: Use an internet-connected node to message Emergency Alerts from FEMA @@ -61,16 +84,18 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo ### File Monitor Alerts - **File Monitor**: Monitor a flat/text file for changes, broadcast the contents of the message to the mesh channel. -- **News File**: On request of news, the contents of the file are returned. +- **News File**: On request of news, the contents of the file are returned. Can also call multiple news sources or files. +- **Shell Command Access**: Pass commands via DM directly to the host OS with replay protection. ### Data Reporting - **HTML Generator**: Visualize bot traffic and data flows with a built-in HTML generator for [data reporting](logs/README.md). +- **RSS and news feeds**: Get data in mesh from many sources! ### Robust Message Handling - **Message Chunking**: Automatically chunk messages over 160 characters to ensure higher delivery success across hops. ## Getting Started -This project is developed on Linux (specifically a Raspberry Pi) but should work on any platform where the [Meshtastic protobuf API](https://meshtastic.org/docs/software/python/cli/) modules are supported, and with any compatible [Meshtastic](https://meshtastic.org/docs/getting-started/) hardware. For pico or low-powered devices, see projects for embedding, [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox). ๐Ÿฅ” Please use responsibly and follow local rulings for such equipment. This project captures packets, logs them, and handles over the air communications which can include PII such as GPS locations. +This project is developed on Linux (specifically a Raspberry Pi) but should work on any platform where the [Meshtastic protobuf API](https://meshtastic.org/docs/software/python/cli/) modules are supported, and with any compatible [Meshtastic](https://meshtastic.org/docs/getting-started/) hardware. For pico or low-powered devices, see projects for embedding, [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox) for running on luckfox hardware. If you need a local console consider the [firefly](https://github.com/pdxlocations/firefly) project. ๐Ÿฅ” Please use responsibly and follow local rulings for such equipment. This project captures packets, logs them, and handles over the air communications which can include PII such as GPS locations. ### Quick Setup #### Clone the Repository @@ -86,31 +111,35 @@ git clone https://github.com/spudgunman/meshing-around ### Networking | Command | Description | โœ… Works Off-Grid | |---------|-------------|- -| `ping`, `ack` | Return data for signal. Example: `ping 15 #DrivingI5` (activates auto-ping every 20 seconds for count 15) | โœ… | +| `ping`, `ack` | Return data for signal. Example: `ping 15 #DrivingI5` (activates auto-ping every 20 seconds for count 15 via DM only) | โœ… | | `cmd` | Returns the list of commands (the help message) | โœ… | | `history` | Returns the last commands run by user(s) | โœ… | +| `leaderboard` | Shows extreme mesh metrics like lowest battery ๐Ÿชซ `leaderboard reset` allows admin reset | โœ… | | `lheard` | Returns the last 5 heard nodes with SNR. Can also use `sitrep` | โœ… | | `motd` | Displays the message of the day or sets it. Example: `motd $New Message Of the day` | โœ… | | `sysinfo` | Returns the bot node telemetry info | โœ… | -| `test` | used to test the limits of data transfer `test 4` sends data to the maxBuffer limit (default 220) | โœ… | +| `test` | used to test the limits of data transfer (`test 4` sends data to the maxBuffer limit default 200 charcters) via DM only | โœ… | | `whereami` | Returns the address of the sender's location if known | | `whoami` | Returns details of the node asking, also returned when position exchanged ๐Ÿ“ | โœ… | | `whois` | Returns details known about node, more data with bbsadmin node | โœ… | +| `echo` | Echo string back, disabled by default | โœ… | +| `bannode` | Admin option to prevent a node from using bot. `bannode list` will load and use the data/bbs_ban_list.txt db | โœ… | -### Radio Propagation & Weather Forcasting +### Radio Propagation & Weather Forecasting | Command | Description | | |---------|-------------|------------------- | `ea` and `ealert` | Return FEMA iPAWS/EAS alerts in USA or DE Headline or expanded details for USA | | +| `earthquake` | Returns the largest and number of USGS events for the location | | | `hfcond` | Returns a table of HF solar conditions | | | `rlist` | Returns a table of nearby repeaters from RepeaterBook | | -| `riverflow` | Return information from NOAA for river flow info. Example: `riverflow modules/settings.py`| | +| `riverflow` | Return information from NOAA for river flow info. | | | `solar` | Gives an idea of the x-ray flux | | | `sun` and `moon` | Return info on rise and set local time | โœ… | | `tide` | Returns the local tides (NOAA data source) | | | `valert` | Returns USGS Volcano Data | | | `wx` | Return local weather forecast, NOAA or Open Meteo (which also has `wxc` for metric and imperial) | | | `wxa` and `wxalert` | Return NOAA alerts. Short title or expanded details | | -| `mwx` | Return the NOAA Coastal Marine Forcast data | | +| `mwx` | Return the NOAA Coastal Marine Forecast data | | ### Bulletin Board & Mail | Command | Description | | @@ -124,7 +153,7 @@ git clone https://github.com/spudgunman/meshing-around | `bbslink` | Links Bulletin Messages between BBS Systems | โœ… | | `email:` | Sends email to address on file for the node or `email: bob@test.net # hello from mesh` | | | `sms:` | Send sms-email to multiple address on file | | -| `setemail`| Sets the email for easy communciations | | +| `setemail`| Sets the email for easy communications | | | `setsms` | Adds the SMS-Email for quick communications | | | `clearsms` | Clears all SMS-Emails on file for node | | @@ -132,10 +161,13 @@ git clone https://github.com/spudgunman/meshing-around | Command | Description | | |---------|-------------|- | `askai` and `ask:` | Ask Ollama LLM AI for a response. Example: `askai what temp do I cook chicken` | โœ… | -| `messages` | Replays the last messages heard, like Store and Forward | โœ… | -| `readnews` | returns the contents of a file (news.txt, by default) via the chunker on air | โœ… | +| `messages` | Replays the last messages heard on device, like Store and Forward, returns the PublicChannel and Current | โœ… | +| `readnews` | returns the contents of a file (data/news.txt, by default) can also `news mesh` via the chunker on air | โœ… | +| `readrss` | returns a set RSS feed on air | | | `satpass` | returns the pass info from API for defined NORAD ID in config or Example: `satpass 25544,33591`| | -| `wiki:` | Searches Wikipedia and returns the first few sentences of the first result if a match. Example: `wiki: lora radio` | +| `wiki:` | Searches Wikipedia (or local Kiwix server) and returns the first few sentences of the first result if a match. Example: `wiki: lora radio` | +| `howfar` | returns the distance you have traveled since your last HowFar. `howfar reset` to start over | โœ… | +| `howtall` | returns height of something you give a shadow by using sun angle | โœ… | ### CheckList | Command | Description | | @@ -144,7 +176,7 @@ git clone https://github.com/spudgunman/meshing-around | `checkout` | Checkout the node in the checklist database, checkout all from node | โœ… | | `checklist` | Display the checklist database, with note | โœ… | -### Games (via DM) +### Games (via DM only) | Command | Description | | |---------|-------------|- | `blackjack` | Plays Blackjack (Casino 21) | โœ… | @@ -152,11 +184,22 @@ git clone https://github.com/spudgunman/meshing-around | `golfsim` | Plays a 9-hole Golf Simulator | โœ… | | `hamtest` | FCC/ARRL Quiz `hamtest general` or `hamtest extra` and `score` | โœ… | | `hangman` | Plays the classic word guess game | โœ… | -| `joke` | Tells a joke | โœ… | +| `joke` | Tells a joke | | | `lemonstand` | Plays the classic Lemonade Stand finance game | โœ… | | `mastermind` | Plays the classic code-breaking game | โœ… | +| `survey` | Issues out a survey to the user | โœ… | +| `quiz` | QuizMaster Bot `q: ?` for more | โœ… | +| `tic-tac-toe`| Plays the game classic game | โœ… | | `videopoker` | Plays basic 5-card hold Video Poker | โœ… | +#### QuizMaster +To use QuizMaster the bbs_admin_list is the QuizMaster, who can `q: start` and `q: stop` to start and stop the game, `q: broadcast ` to send a message to all players. +Players can `q: join` to join the game, `q: leave` to leave the game, `q: score` to see their score, and `q: top` to see the top 3 players. +To Answer a question, just type the answer prefixed with `q: ` + +#### Survey +To use the Survey feature edit the json files in data/survey multiple surveys are possible such as `survey snow` you can pull data back with `survey report` or `survey report snow` + ## Other Install Options ### Docker Installation - handy for windows @@ -187,7 +230,7 @@ meshtastic --ble-scan # config.ini # type can be serial, tcp, or ble. # port is the serial port to use; commented out will try to auto-detect -# hostname is the IP address of the device to connect to for TCP type +# hostname is the IP/DNS and port for tcp type default is host:4403 # mac is the MAC address of the device to connect to for BLE type [interface] @@ -211,6 +254,7 @@ defaultChannel = 0 ignoreDefaultChannel = False # ignoreDefaultChannel, the bot will ignore the default channel set above ignoreChannels = # ignoreChannels is a comma separated list of channels to ignore, e.g. 4,5 cmdBang = False # require ! to be the first character in a command +explicitCmd = True # require explicit command, the message will only be processed if it starts with a command word disable to get more activity ``` ### Location Settings @@ -221,12 +265,18 @@ The weather forecasting defaults to NOAA, for locations outside the USA, you can enabled = True lat = 48.50 lon = -123.0 +# To fuzz the location of the above +fuzzConfigLocation = True +# Fuzz all values in all data +fuzzItAll = False + UseMeteoWxAPI = True -# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts (PZZ) and tide cmd -pzzEnabled = False -pzzZoneID = 132 # My Forecast Zone ID, https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map' and select PZZ zone -pzzForecastDays = 3 # number of data points to return, default is 3 +coastalEnabled = False # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide +# Find the correct coastal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/ +# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map' +myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt # myCoastalZone is the .txt file with the forecast data +coastalForecastDays = 3 # number of data points to return, default is 3 ``` ### Module Settings @@ -262,6 +312,7 @@ SentryHoldoff = 2 # channel to send a message to when the watchdog is triggered sentryIgnoreList = # list of ignored nodes numbers ex: 2813308004,4258675309 highFlyingAlert = True # HighFlying Node alert highFlyingAlertAltitude = 2000 # Altitude in meters to trigger the alert +highflyOpenskynetwork = True # check with OpenSkyNetwork if highfly detected for aircraft ``` ### E-Mail / SMS Settings @@ -270,8 +321,8 @@ To enable connectivity with SMTP allows messages from meshtastic into SMTP. The ```ini [smtp] # enable or disable the SMTP module, minimum required for outbound notifications -enableSMTP = True # enable or disable the IMAP module for inbound email, not implimented yet -enableImap = False # list of Sysop Emails seperate with commas, used only in emergemcy responder currently +enableSMTP = True # enable or disable the IMAP module for inbound email, not implemented yet +enableImap = False # list of Sysop Emails separate with commas, used only in emergency responder currently sysopEmails = # See config.template for all the SMTP settings SMTP_SERVER = smtp.gmail.com @@ -314,10 +365,9 @@ myRegionalKeysDE = 110000000000,120510000000 This uses the defined lat-long of the bot for collecting of data from the API. see [File-Monitoring](#File-Monitoring) for ideas to collect EAS alerts from a RTL-SDR. ```ini -# EAS Alert Broadcast -wxAlertBroadcastEnabled = True -# EAS Alert Broadcast Channels -wxAlertBroadcastCh = 2,4 + +wxAlertBroadcastEnabled = True # EAS Alert Broadcast +wxAlertBroadcastCh = 2,4 # EAS Alert Broadcast Channels ignoreEASenable = True # Ignore any headline that includes followig word list ignoreEASwords = test,advisory ``` @@ -329,7 +379,7 @@ Volcano Alerts use lat/long to determine ~1000km radius ```ini [location] # USGS Hydrology unique identifiers, LID or USGS ID https://waterdata.usgs.gov -riverListDefault = 14144700 +riverList = 14144700 # example Mouth of Columbia River # USGS Volcano alerts Enable USGS Volcano Alert Broadcast volcanoAlertBroadcastEnabled = False @@ -346,12 +396,12 @@ repeater_channels = [2, 3] ``` ### Ollama (LLM/AI) Settings -For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma2:2b`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time. +For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma3:270m`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time. ```ini # Enable ollama LLM see more at https://ollama.com ollama = True # Ollama model to use (defaults to gemma2:2b) -ollamaModel = gemma2 #ollamaModel = llama3.1 +ollamaModel = gemma3:latest # Ollama model to use (defaults to gemma3:270m) ollamaHostName = http://localhost:11434 # server instance to use (defaults to local machine install) ``` @@ -359,12 +409,42 @@ Also see `llm.py` for changing the defaults of: ```ini # LLM System Variables +rawQuery = True # if True, the input is sent raw to the LLM if False, it is processed by the meshBotAI template + +# Used in the meshBotAI template (legacy) llmEnableHistory = True # enable history for the LLM model to use in responses adds to compute time llmContext_fromGoogle = True # enable context from google search results helps with responses accuracy googleSearchResults = 3 # number of google search results to include in the context more results = more compute time ``` Note for LLM in docker with [NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/docker-specialized.html). Needed for the container with ollama running. +### Wikipedia Search Settings +The Wikipedia search module can use either the online Wikipedia API or a local Kiwix server for offline wiki access. Kiwix is especially useful for mesh networks operating in remote or offline environments. + +```ini +# Enable or disable the wikipedia search module +wikipedia = True + +# Use local Kiwix server instead of online Wikipedia +# Set to False to use online Wikipedia (default) +useKiwixServer = False + +# Kiwix server URL (only used if useKiwixServer is True) +kiwixURL = http://127.0.0.1:8080 + +# Kiwix library name (e.g., wikipedia_en_100_nopic_2024-06) +# Find available libraries at https://library.kiwix.org/ +kiwixLibraryName = wikipedia_en_100_nopic_2024-06 +``` + +To set up a local Kiwix server: +1. Install Kiwix tools: https://kiwix.org/en/ `sudo apt install kiwix-tools -y` +2. Download a Wikipedia ZIM file to `data/`: https://library.kiwix.org/ `wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_nopic_2025-09.zim` +3. Run the server: `kiwix-serve --port 8080 wikipedia_en_100_nopic_2025-09.zim` +4. Set `useKiwixServer = True` in your config.ini + +The bot will automatically extract and truncate content to fit Meshtastic's message size limits (~500 characters). + ### Radio Monitoring A module allowing a Hamlib compatible radio to connect to the bot. When functioning, it will message the configured channel with a message of in use. **Requires hamlib/rigctld to be running as a service.** @@ -385,12 +465,15 @@ Some dev notes for ideas of use ```ini [fileMon] filemon_enabled = True -file_path = alert.txt -broadcastCh = 2,4 -enable_read_news = False +file_path = alert.txt # text file to monitor for changes +broadcastCh = 2 # channel to send the message to can be 2,3 multiple channels comma separated +enable_read_news = False # news command will return the contents of a text file news_file_path = news.txt news_random_line = False # only return a single random line from the news file -enable_runShellCmd = False # enables running of bash commands runShell.sh demo for sysinfo +enable_runShellCmd = False # enable the use of exernal shell commands, this enables more data in `sysinfo` DM +# if runShellCmd and you think it is safe to allow the x: command to run +# direct shell command handler the x: command in DMs user must be in bbs_admin_list +allowXcmd = True ``` #### Offline EAS @@ -416,7 +499,12 @@ rtl_fm -f 162425000 -s 22050 | multimon-ng -t raw -a EAS /dev/stdin | python eas ``` #### Newspaper on mesh -a newspaper could be built by external scripts. could use Ollama to compile text via news web pages and write news.txt +Maintain multiple news sources. Each source should be a file named `{source}_news.txt` in the `data/` directory (for example, `data/mesh_news.txt`). +- To read the default news, use the `readnews` command (reads from `data/news.txt`. +- To read a specific source, use `readnews abc` to read from `data/abc_news.txt`. + +This allows you to organize and access different news feeds or categories easily. +External scripts can update these files as needed, and the bot will serve the latest content on request. ### Greet new nodes QRZ module This isnt QRZ.com this is Q code for who is calling me, this will track new nodes and say hello @@ -431,21 +519,17 @@ training = True # Training mode will not send the hello message to new nodes, us In the config.ini enable the module ```ini [scheduler] -# enable or disable the scheduler module -enabled = False -# interface to send the message to -interface = 1 -# channel to send the message to +enabled = False # enable or disable the scheduler module +interface = 1 # channel to send the message to channel = 2 message = "MeshBot says Hello! DM for more info." -# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun -value = -# interval to use when time is not set (e.g. every 2 days) -interval = -# time of day in 24:00 hour format when value is 'day' and interval is not set -time = +value = # value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun. +# value can also be joke (everyXmin) or weather (hour) for special scheduled messages +# custom for module/scheduler.py custom schedule examples +interval = # interval to use when time is not set (e.g. every 2 days) +time = # time of day in 24:00 hour format when value is 'day' and interval is not set ``` - The basic brodcast message can be setup in condig.ini. For advanced, See mesh_bot.py around the bottom of file, line [1491](https://github.com/SpudGunMan/meshing-around/blob/e94581936530c76ea43500eebb43f32ba7ed5e19/mesh_bot.py#L1491) to edit the schedule. See [schedule documentation](https://schedule.readthedocs.io/en/stable/) for more. Recomend to backup changes so they dont get lost. + The basic brodcast message can be setup in condig.ini. For advanced, See the [modules/scheduler.py](modules/scheduler.py) to edit the schedule. See [schedule documentation](https://schedule.readthedocs.io/en/stable/) for more. Recomend to backup changes so they dont get lost. ```python #Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1 @@ -456,7 +540,7 @@ schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now ``` #### BBS Link -The scheduler also handles the BBS Link Brodcast message, this would be an esxample of a mesh-admin channel on 8 being used to pass BBS post traffic between two bots as the initator, one direction pull. +The scheduler also handles the BBS Link Broadcast message, this would be an example of a mesh-admin channel on 8 being used to pass BBS post traffic between two bots as the initiator, one direction pull. The message just needs to have bbslink ```python # Send bbslink looking for peers every other day at 10:00 using send_message function to channel 8 on device 1 schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", 8, 0, 1)) @@ -466,8 +550,20 @@ bbslink_enabled = True bbslink_whitelist = # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all ``` +### Firmware 2.6 DM Key, and 2.7 CLIENT_BASE Favorite Nodes +Firmware 2.6 introduced [PKC](https://meshtastic.org/blog/introducing-new-public-key-cryptography-in-v2_5/), enabling secure private messaging by adding necessary keys to each node. To fully utilize this feature, you should add favorite nodesโ€”such as BBS adminsโ€”to your nodeโ€™s favorites list to ensure their keys are retained. A helper script is provided to simplify this process: +- Run the helper script from the main program directory: `python3 script/addFav.py` +- By default, this script adds nodes from `bbs_admin_list` and `bbslink_whitelist` +- If using a virtual environment, run: `launch.sh addfav` + +To configure favorite nodes, add their numbers to your config file: +```conf +[general] +favoriteNodeList = # list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py +``` + ### MQTT Notes -There is no direct support for MQTT in the code, however, reports from Discord are that using [meshtasticd](https://meshtastic.org/docs/hardware/devices/linux-native-hardware/) with no radio and attaching the bot to the software node, which is MQTT-linked, allows routing. Tested working fully Firmware:2.5.15.79da236 with [mosquitto](https://meshtastic.org/docs/software/integrations/mqtt/mosquitto/). +There is no direct support for MQTT in the code, however, reports from Discord are that using [meshtasticd](https://meshtastic.org/docs/hardware/devices/linux-native-hardware/) with no radio and attaching the bot to the software node, which is MQTT-linked, allows routing. Tested working fully Firmware:2.6.11 with [mosquitto](https://meshtastic.org/docs/software/integrations/mqtt/mosquitto/). ~~There also seems to be a quicker way to enable MQTT by having your bot node with the enabled [serial](https://meshtastic.org/docs/configuration/module/serial/) module with echo enabled and MQTT uplink and downlink. These two~~ @@ -501,7 +597,9 @@ I used ideas and snippets from other responder bots and want to call them out! - **dj505**: trying it on windows! - **mikecarper**: ideas, and testing. hamtest - **c.merphy360**: high altitude alerts -- **Cisien, bitflip, **Woof**, **propstg**, **trs2982**, **Josh** and Hailo1999**: For testing and feature ideas on Discord and GitHub. +- **Iris**: testing and finding ๐Ÿž +- **FJRPiolt**: testing bugs out!! +- **Cisien, bitflip, Woof, propstg, snydermesh, trs2982, F0X, mesb1, and Hailo1999**: For testing and feature ideas on Discord and GitHub. - **Meshtastic Discord Community**: For tossing out ideas and testing code. ### Tools diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6b49d67 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| git pull| :white_check_mark: | + +## Reporting a Vulnerability + +If its serious, its likley big. otherwise post issues, reachout on discord. diff --git a/config.template b/config.template index 1b6dace..cd26a8d 100644 --- a/config.template +++ b/config.template @@ -1,7 +1,7 @@ #config.ini # type can be serial, tcp, or ble # port is the serial port to use, commented out will try to auto-detect -# hostname is the IP address of the device to connect to for tcp type +# hostname is the IP/DNS and port for tcp type default is host:4403 # mac is the MAC address of the device to connect to for ble type [interface] @@ -36,6 +36,10 @@ ignoreDefaultChannel = False ignoreChannels = # require ! to be the first character in a command cmdBang = False +# require explicit command, the message will only be processed if it starts with a command word +explicitCmd = True +# list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py +favoriteNodeList = # motd is reset to this value on boot motd = Thanks for using MeshBOT! Have a good day! @@ -51,22 +55,40 @@ DadJokesEmoji = False # enable or disable the Solar module spaceWeather = True +# enable or disable the RSS module, and truncate the story +rssEnable = True +rssFeedURL = http://www.hackaday.com/rss.xml,http://rss.slashdot.org/Slashdot/slashdotMain +# RSS feed names must match the order of the URLs above, default is used if no match +rssFeedNames = default,slashdot +rssMaxItems = 3 +rssTruncate = 100 + # enable or disable the wikipedia search module wikipedia = True +# Use local Kiwix server instead of online Wikipedia +# Set to False to use online Wikipedia, or provide Kiwix server URL +useKiwixServer = False +# Kiwix server URL (e.g., http://127.0.0.1:8080) +kiwixURL = http://127.0.0.1:8080 +# Kiwix library name (e.g., wikipedia_en_100_nopic_2025-09) +kiwixLibraryName = wikipedia_en_100_nopic_2025-09 # Enable ollama LLM see more at https://ollama.com ollama = False -# Ollama model to use (defaults to gemma2:2b) -# ollamaModel = llama3.1 +# Ollama model to use (defaults to gemma3:270m) +# ollamaModel = gemma3:latest # server instance to use (defaults to local machine install) ollamaHostName = http://localhost:11434 # Produce LLM replies to messages that aren't commands? # If False, the LLM only replies to the "ask:" and "askai" commands. llmReplyToNonCommands = True +# if True, the input is sent raw to the LLM, if False uses legacy template query +rawLLMQuery = True # StoreForward Enabled and Limits StoreForward = True StoreLimit = 3 +reverseSF = False # history command enableCmdHistory = True @@ -76,7 +98,7 @@ lheardCmdIgnoreNodes = # 24 hour clock zuluTime = False # wait time for URL requests -urlTimeout = 10 +urlTimeout = 15 # logging to file of the non Bot messages LogMessagesToFile = False @@ -90,6 +112,11 @@ log_backup_count = 32 #Do not retry enabling interface if it fails, just exit to let OS restart the bot dont_retry_disconnect = False +#echo command, will echo back your message as the bot +enableEcho = False +# command will only echo 1:1 if sent on this channel, otherwise it will prepend @yourname +echoChannel = 9 + [emergencyHandler] # enable or disable the emergency response handler enabled = False @@ -100,21 +127,38 @@ alert_interface = 1 [sentry] # detect anyone close to the bot SentryEnabled = True -emailSentryAlerts = False -# radius in meters to detect someone close to the bot -SentryRadius = 100 -# channel to send a message to when the watchdog is triggered +# device interface and channel to send the alert message to +SentryInterface = 1 SentryChannel = 2 -# holdoff time multiplied by seconds(20) of the watchdog -SentryHoldoff = 9 +emailSentryAlerts = False +# Enable detection sensor alert, requires external GPIO sensor connected to node +detectionSensorAlert = False + # list of ignored nodes numbers ex: 2813308004,4258675309 sentryIgnoreList = +# list of watched nodes numbers ex: 2813308004,4258675309 +sentryWatchList = + +# radius in meters to detect someone close to the bot +SentryRadius = 100 +# holdoff time multiplied by seconds(20) of the watchdog +SentryHoldoff = 9 + +# Enable running external shell command when sentry alert is triggered +cmdShellSentryAlerts = False +# External shell command to run when sentry alert is triggered +sentryAlertNear = sentry_alert_near.sh +sentryAlertAway = sentry_alert_away.sh # HighFlying Node alert highFlyingAlert = True # Altitude in meters to trigger the alert highFlyingAlertAltitude = 2000 +# check with OpenSkyNetwork if highfly detected for aircraft +highflyOpenskynetwork = True # Channel to send Alert when the high flying node is detected +highFlyingAlertInterface = 1 +# to disable OTA alert set to unused channel like 9 highFlyingAlertChannel = 2 # list of nodes numbers to ignore high flying alert ex: 2813308004,4258675309 highFlyingIgnoreList = @@ -128,18 +172,22 @@ bbs_admin_list = # enable bbs synchronization with other nodes bbslink_enabled = False # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all -bbslink_whitelist = +bbslink_whitelist = +# enable API script access (increases disk i/o) +bbsAPI_enabled = False # location module [location] enabled = True lat = 48.50 lon = -123.0 +fuzzConfigLocation = True +fuzzItAll = False # Default to metric units rather than imperial useMetric = False -# repeaterList lookup location (rbook / artsci) +# repeaterList lookup location (rbook / artsci / False) repeaterLookup = rbook # NOAA weather forecast days @@ -150,15 +198,19 @@ NOAAalertCount = 2 # use Open-Meteo API for weather data not NOAA useful for non US locations UseMeteoWxAPI = False -# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts (PZZ) and tide cmd -pzzEnabled = False -# My Forecast Zone ID, https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map' and select PZZ zone -pzzZoneID = 132 +# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide +coastalEnabled = False +# Find the correct costal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/ +# pz = Puget Sound, ph = Honolulu HI, gm = Florida Keys, pk = Alaska +# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map' +# myCoastalZone is the .txt file with the forecast data +myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt # number of data points to return, default is 3 -pzzForecastDays = 3 +coastalForecastDays = 3 -# USGS Hydrology unique identifiers, LID or USGS ID https://waterdata.usgs.gov -riverListDefault = +# NOAA USGS Hydrology river identifiers, LID or USGS ID https://waterdata.usgs.gov 12484500 Columbia River at The Dalles, OR +# for multiple rivers use comma separated list e.g. 12484500,14105700 +riverList = # NOAA EAS Alert Broadcast wxAlertBroadcastEnabled = False @@ -196,7 +248,7 @@ enableDEalerts = False myRegionalKeysDE = 110000000000,120510000000 # Satalite Pass Prediction -# Register for free API https://www.n2yo.com/login/ +# Register for free API https://www.n2yo.com/login/ personal data page at bottom 'Are you developer?' n2yoAPIKey = # NORAD list https://www.n2yo.com/satellites/ satList = 25544,7530 @@ -232,7 +284,11 @@ interface = 1 # channel to send the message to channel = 2 message = "MeshBot says Hello! DM for more info." -# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun +# enable overides the above and uses the motd as the message +schedulerMotd = False +# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun. +# value can also be joke (everyXmin) or weather (hour) for special scheduled messages +# custom for module/scheduler.py custom schedule examples value = # interval to use when time is not set (e.g. every 2 days) interval = @@ -243,7 +299,9 @@ time = # using Hamlib rig control will monitor and alert on channel use enabled = False rigControlServerAddress = localhost:4532 -# broadcast to all nodes on the channel can also be = 2,3 +# device interface to send the message to +sigWatchBroadcastInterface = 1 +# broadcast channel can also be a comma separated list of channels sigWatchBroadcastCh = 2 # minimum SNR as reported by radio via hamlib signalDetectionThreshold = -10 @@ -252,17 +310,40 @@ signalHoldTime = 10 # the following are combined to reset the monitor signalCooldown = 5 signalCycleLimit = 5 +# enable VOX detection using default input +voxDetectionEnabled = False +# description to use in the alert message +voxDescription = VOX +useLocalVoxModel = False +voxLanguage = en-us +voxInputDevice = default +voxOnTrapList = True +voxTrapList = chirpy +voxEnableCmd = True + [fileMon] filemon_enabled = False +# text file to monitor for changes file_path = alert.txt +# channel to send the message to can be 2,3 multiple channels comma separated broadcastCh = 2 + +# news command will return the contents of a text file enable_read_news = False -news_file_path = news.txt +news_file_path = ../data/news.txt # only return a single random line from the news file news_random_line = False -# enable the use of exernal shell commands + +# enable the use of exernal shell commands, this enables some data in `sysinfo` enable_runShellCmd = False +# if runShellCmd and you think it is safe to allow the x: command to run +# direct shell command handler the x: command in DMs +allowXcmd = False +# Enable 2 factor authentication for x: commands +2factor_enabled = True +# time in seconds to wait for the correct 2FA answer +2factor_timeout = 100 [smtp] # enable or disable the SMTP module @@ -293,6 +374,7 @@ IMAP_FOLDER = inbox [games] # if hop limit for the user exceeds this value, the message will be dropped game_hop_limit = 5 +disable_emojis = False # enable or disable the games module(s) dopeWars = True lemonade = True @@ -302,19 +384,39 @@ mastermind = True golfsim = True hangman = True hamtest = True +tictactoe = True + +# enable or disable the quiz game module questions are in data/quiz.json +quiz = False + +# enable or disable the survey game module questions are in data/survey/*_survey.json +survey = False +# this is the default survey to use when command givcen, from data/survey/example_survey.json +defaultSurvey = example +# Whether to record user ID in responses +surveyRecordID=True +# Whether to record location on start of survey +surveyRecordLocation=True [messagingSettings] # delay in seconds for response to avoid message collision /throttling responseDelay = 2.2 # delay in seconds for splits in messages to avoid message collision /throttling splitDelay = 2.5 -# message chunk size for sending at high success rate, chunkr allows exceeding by 3 characters +# message chunk size in charcters, chunkr allows exceeding by 3 characters MESSAGE_CHUNK_SIZE = 160 # Request Acknowledgement of message OTA wantAck = False -# Max limit buffer for radio testing +# Max limit buffer for radio testing in bytes maxBuffer = 200 #Enable Extra logging of Hop count data enableHopLogs = False - - +# Noisy Node Telemetry Logging and packet threshold +noisyNodeLogging = False +noisyTelemetryLimit = 5 +logMetaStats = True +# Enable detailed packet logging all packets +DEBUGpacket = False +# metaPacket detailed logging, the filter negates the port ID +debugMetadata = False +metadataFilter = TELEMETRY_APP,POSITION_APP diff --git a/data/mesh_news.txt b/data/mesh_news.txt new file mode 100644 index 0000000..1017712 --- /dev/null +++ b/data/mesh_news.txt @@ -0,0 +1 @@ +Today in meshtastic you are looking at the coolest bot on the block. \ No newline at end of file diff --git a/news.txt b/data/news.txt similarity index 100% rename from news.txt rename to data/news.txt diff --git a/data/quiz_questions.json b/data/quiz_questions.json new file mode 100644 index 0000000..67a8be6 --- /dev/null +++ b/data/quiz_questions.json @@ -0,0 +1,16 @@ +[ + { + "question": "Which RFband is commonly used by Meshtastic devices in US regions?", + "answers": ["2.4 GHz", "433 MHz", "900 MHz", "5.8 GHz"], + "correct": 2 + }, + { + "question": "Yogi the bear ๐Ÿป likes what food?", + "answers": ["Picnic baskets", "Fish", "Burgers", "Hot dogs"], + "correct": 0 + }, + { + "question": "What is the password for the Meshtastic MQTT broker?", + "answer": "large4cats" + } +] \ No newline at end of file diff --git a/data/surveys/example_survey.json b/data/surveys/example_survey.json new file mode 100644 index 0000000..cf580fd --- /dev/null +++ b/data/surveys/example_survey.json @@ -0,0 +1,15 @@ +[ + { + "type": "multiple_choice", + "question": "How Did you hear about us?", + "options": ["Meshtastic", "Discord", "Friend", "Other"] + }, + { + "type": "integer", + "question": "How many nodes do you own?" + }, + { + "type": "text", + "question": "What feature would you like to see next?" + } +] \ No newline at end of file diff --git a/data/surveys/snow_survey.json b/data/surveys/snow_survey.json new file mode 100644 index 0000000..08e3cd1 --- /dev/null +++ b/data/surveys/snow_survey.json @@ -0,0 +1,15 @@ +[ + { + "type": "multiple_choice", + "question": "How often do you experience snowfall in your area?", + "options": ["Never", "Rarely", "Sometimes", "Often", "Every winter"] + }, + { + "type": "integer", + "question": "What was the deepest snowfall (in inches) you've measured at your location?" + }, + { + "type": "text", + "question": "Describe any challenges you face during heavy snowfall." + } +] \ No newline at end of file diff --git a/etc/mesh_bot.tmp b/etc/mesh_bot.tmp index 0647799..b6e0ef0 100644 --- a/etc/mesh_bot.tmp +++ b/etc/mesh_bot.tmp @@ -14,6 +14,8 @@ Group=pi WorkingDirectory=/dir/ ExecStart=python3 mesh_bot.py ExecStop=pkill -f mesh_bot.py +Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # Disable Python's buffering of STDOUT and STDERR, so that output from the # service shows up immediately in systemd's logs diff --git a/etc/mesh_bot_w3.tmp b/etc/mesh_bot_w3.tmp index 3a42f10..16ffcf8 100644 --- a/etc/mesh_bot_w3.tmp +++ b/etc/mesh_bot_w3.tmp @@ -14,6 +14,8 @@ Group=pi WorkingDirectory=/dir/ ExecStart=python3 modules/web.py ExecStop=pkill -f mesh_bot_w3.py +Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # Disable Python's buffering of STDOUT and STDERR, so that output from the # service shows up immediately in systemd's logs @@ -21,3 +23,6 @@ Environment=PYTHONUNBUFFERED=1 Restart=on-failure Type=notify #try simple if any problems + +[Install] +WantedBy=default.target diff --git a/etc/pong_bot.tmp b/etc/pong_bot.tmp index 78beec0..6b0a70c 100644 --- a/etc/pong_bot.tmp +++ b/etc/pong_bot.tmp @@ -14,6 +14,8 @@ Group=pi WorkingDirectory=/dir/ ExecStart=python3 pong_bot.py ExecStop=pkill -f pong_bot.py +Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # Disable Python's buffering of STDOUT and STDERR, so that output from the # service shows up immediately in systemd's logs diff --git a/script/send-environment-metrics.py b/etc/send-environment-metrics.py similarity index 100% rename from script/send-environment-metrics.py rename to etc/send-environment-metrics.py diff --git a/etc/simulator.py b/etc/simulator.py index d88398a..1bbaee7 100644 --- a/etc/simulator.py +++ b/etc/simulator.py @@ -9,6 +9,7 @@ projectName = "example_handler" # name of _handler function to match the functio randomNode = False # Set to True to use random node IDs # bot.py Simulated functions +deviceID = 1 # represents the device/node number def get_NodeID(): nodeList = [4258675309, 1212121212, 1234567890, 9876543210] if randomNode: @@ -16,22 +17,43 @@ def get_NodeID(): else: nodeID = nodeList[0] return nodeID +nodeID = get_NodeID() # assign a nodeID def get_name_from_number(nodeID, length='short', interface=1): # return random name for nodeID names = ["Max","Molly","Jake","Kelly"] return names[nodeID % len(names)] +#simulate GPS locations for testing +locations = [ + (48.200909, -123.25719), + (48.330283,-123.260703), + (48.342735,-122.987911), + (48.205591,-122.998448) + ] +lat, lon = random.choice(locations) # pick a random location +location = f"{lat},{lon}" # # end Initialization of the tool + + + + # # Function to handle, or the project in test +#from modules.llm import * # Import the LLM module +# # Project handler function code here - +# example handler function canada() def example_handler(message, nodeID, deviceID): - readableTime = time.ctime(time.time()) - msg = "Hello World! " - msg += f" You are Node ID: {nodeID} " - msg += f" Its: {readableTime} " - msg += f" You just sent: {message}" - return msg + if message != "": + # put code in test here + msg = f"Hello {get_name_from_number(nodeID)}, simulator ready for testing {projectName} project! on device {deviceID}" + msg += f" Your location is {location}" + msg += f" you said: {message}" + + + return msg + + + # # end of function test code @@ -42,7 +64,7 @@ if __name__ == '__main__': # represents the bot's main loop nodeInt = 1 # represents the device/node number logger.info(f"System: Meshing-Around Simulator Starting for {projectName}") nodeID = get_NodeID() # assign a nodeID - projectResponse = globals()[projectName]("", nodeID, nodeInt) # Call the project handler under test + projectResponse = globals()[projectName]("", nodeID, deviceID) # call the handler function once to start while True: # represents the onReceive() loop in the bot.py projectResponse = "" responseLength = 0 @@ -51,7 +73,7 @@ if __name__ == '__main__': # represents the bot's main loop packet = input(f"CLIENT {nodeID} INPUT: " ) # Emulate the client input if packet != "": #try: - projectResponse = globals()[projectName](message = packet, nodeID = nodeID, deviceID = nodeInt) + projectResponse = globals()[projectName](message = packet, nodeID = nodeID, deviceID = deviceID) # call the handler function # except Exception as e: # logger.error(f"System: Handler: {e}") # projectResponse = "Error in handler" diff --git a/install.sh b/install.sh index 4c44c2f..6f994a0 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,12 @@ printf "Installer works best in raspian/debian/ubuntu or foxbuntu embedded syste printf "If there is a problem, try running the installer again.\n" printf "\nChecking for dependencies...\n" +# fuse +fi [[ -f config.ini ]]; then + printf "\nDetected existing installation, please backup and remove existing installation before proceeding\n" + exit 1 +fi + # check if we are in /opt/meshing-around if [ $program_path != "/opt/meshing-around" ]; then printf "\nIt is suggested to project path to /opt/meshing-around\n" @@ -207,6 +213,12 @@ sudo chown -R $whoami:$whoami $program_path/logs sudo chown -R $whoami:$whoami $program_path/data echo "Permissions set for meshbot on logs and data directories" +# check and see if some sort of NTP is running +if ! systemctl is-active --quiet ntp.service && \ + ! systemctl is-active --quiet systemd-timesyncd.service && \ + ! systemctl is-active --quiet chronyd.service; then + printf "\nNo NTP service detected, it is recommended to have NTP running for proper bot operation.\n" + # set the correct user in the service file replace="s|User=pi|User=$whoami|g" sed -i $replace etc/pong_bot.service @@ -248,28 +260,46 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then echo "Emoji font installed!, reboot to load the font" fi - printf "\nOptionally if you want to install the multi gig LLM Ollama compnents we will execute the following commands\n" + printf "\nOptionally if you want to install the LLM Ollama compnents we will execute the following commands\n" printf "\ncurl -fsSL https://ollama.com/install.sh | sh\n" - printf "ollama pull gemma2:2b\n" - printf "Total download is multi GB, recomend pi5/8GB or better for this\n" + printf "ollama pull gemma3:270m\n" # ask if the user wants to install the LLM Ollama components printf "\nDo you want to install the LLM Ollama components? (y/n)" read ollama if [[ $(echo "${ollama}" | grep -i "^y") ]]; then curl -fsSL https://ollama.com/install.sh | sh - # ask if want to install gemma2:2b - printf "\n Ollama install done now we can install the Gemma2:2b components\n" - echo "Do you want to install the Gemma2:2b components? (y/n)" + # ask if want to install gemma3:latest + printf "\n Ollama install done now we can install the gemma3:270m components\n" + echo "Do you want to install the gemma3:270m components? (y/n)" read gemma if [[ $(echo "${gemma}" | grep -i "^y") ]]; then - ollama pull gemma2:2b + ollama pull gemma3:270m + fi + fi + + # ask if the user wants to edit the ollama service for API access + if [[ -f /etc/systemd/system/ollama.service ]]; then + printf "\nEdit /etc/systemd/system/ollama.service and add Environment=OLLAMA_HOST=0.0.0.0 for API? (y/n)" + read editollama + if [[ $(echo "${editollama}" | grep -i "^y") ]]; then + replace="s|\[Service\]|\[Service\]\nEnvironment=\"OLLAMA_HOST=0.0.0.0\"|g" + sudo sed -i "$replace" /etc/systemd/system/ollama.service + sudo systemctl daemon-reload + sudo systemctl restart ollama.service + printf "\nOllama service updated and restarted\n" + fi + # assume we want to enable ollama in config.ini + if [[ -f config.ini ]]; then + replace="s|ollama = False|ollama = True|g" + sed -i "$replace" config.ini + printf "\nOllama enabled in config.ini\n" fi fi # document the service install printf "To install the %s service and keep notes, reference following commands:\n\n" "$service" > install_notes.txt - printf "sudo cp %s/etc/%s.service /etc/systemd/system/etc/%s.service\n" "$program_path" "$service" "$service" >> install_notes.txt + printf "sudo cp %s/etc/%s.service /etc/systemd/system/%s.service\n" "$program_path" "$service" "$service" >> install_notes.txt printf "sudo systemctl daemon-reload\n" >> install_notes.txt printf "sudo systemctl enable %s.service\n" "$service" >> install_notes.txt printf "sudo systemctl start %s.service\n" "$service" >> install_notes.txt @@ -281,6 +311,7 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt printf "Reporting chron job added to run report_generator5.py\n" >> install_notes.txt printf "chronjob: %s\n" "$chronjob" >> install_notes.txt + printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt if [[ $(echo "${venv}" | grep -i "^y") ]]; then printf "\nFor running on venv, virtual launch bot with './launch.sh mesh' in path $program_path\n" >> install_notes.txt @@ -326,6 +357,7 @@ else printf "sudo journalctl -u %s.service\n" "$service" >> install_notes.txt printf "sudo systemctl stop %s.service\n" "$service" >> install_notes.txt printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt + printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt fi printf "\nInstallation complete!\n" diff --git a/launch.sh b/launch.sh index 964c81d..f8e3a32 100755 --- a/launch.sh +++ b/launch.sh @@ -26,8 +26,10 @@ elif [ "$1" == "html" ]; then python3 etc/report_generator.py elif [ "$1" == "html5" ]; then python3 etc/report_generator5.py +elif [[ "$1" == add* ]]; then + python3 script/addFav.py else - echo "Please provide a bot to launch (pong/mesh) or a report to generate (html/html5)" + echo "Please provide a bot to launch (pong/mesh) or a report to generate (html/html5) or addFav" exit 1 fi diff --git a/mesh_bot.py b/mesh_bot.py index c1e41ae..ed58017 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -11,16 +11,14 @@ except ImportError: import asyncio import time # for sleep, get some when you can :) import random +from datetime import datetime from modules.log import * from modules.system import * # list of commands to remove from the default list for DM only -restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest"] +restrictedCommands = ["blackjack", "videopoker", "dopewars", "lemonstand", "golfsim", "mastermind", "hangman", "hamtest", "tictactoe", "quiz", "q:", "survey", "s:"] restrictedResponse = "๐Ÿค–only available in a Direct Message๐Ÿ“ต" # "" for none -# Global Variables -DEBUGpacket = False # Debug print the packet rx - def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM): global cmdHistory #Auto response to messages @@ -32,6 +30,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "ack": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "ask:": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel), "askai": lambda: handle_llm(message_from_id, channel_number, deviceID, message, publicChannel), + "bannode": lambda: handle_bbsban(message, message_from_id, isDM), "bbsack": lambda: bbs_sync_posts(message, message_from_id, deviceID), "bbsdelete": lambda: handle_bbsdelete(message, message_from_id), "bbshelp": bbs_help, @@ -44,6 +43,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "checkin": lambda: handle_checklist(message, message_from_id, deviceID), "checklist": lambda: handle_checklist(message, message_from_id, deviceID), "checkout": lambda: handle_checklist(message, message_from_id, deviceID), + "chess": lambda: handle_gTnW(chess=True), "clearsms": lambda: handle_sms(message_from_id, message), "cmd": lambda: handle_cmd(message, message_from_id, deviceID), "cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), @@ -51,7 +51,9 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "dopewars": lambda: handleDopeWars(message, message_from_id, deviceID), "ea": lambda: handle_emergency_alerts(message, message_from_id, deviceID), + "echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number), "ealert": lambda: handle_emergency_alerts(message, message_from_id, deviceID), + "earthquake": lambda: handleEarthquake(message, message_from_id, deviceID), "email:": lambda: handle_email(message_from_id, message), "games": lambda: gamesCmdList, "globalthermonuclearwar": lambda: handle_gTnW(), @@ -60,7 +62,10 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "hangman": lambda: handleHangman(message, message_from_id, deviceID), "hfcond": hf_band_conditions, "history": lambda: handle_history(message, message_from_id, deviceID, isDM), + "howfar": lambda: handle_howfar(message, message_from_id, deviceID, isDM), + "howtall": lambda: handle_howtall(message, message_from_id, deviceID, isDM), "joke": lambda: tell_joke(message_from_id), + "leaderboard": lambda: get_mesh_leaderboard(message, message_from_id, deviceID), "lemonstand": lambda: handleLemonade(message, message_from_id, deviceID), "lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM), "mastermind": lambda: handleMmind(message, message_from_id, deviceID), @@ -71,19 +76,26 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "pinging": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "pong": lambda: "๐Ÿ“PING!!๐Ÿ›œ", - "readnews": lambda: read_news(), + "q:": lambda: quizHandler(message, message_from_id, deviceID), + "quiz": lambda: quizHandler(message, message_from_id, deviceID), + "readnews": lambda: handleNews(message_from_id, deviceID, message, isDM), + "readrss": lambda: get_rss_feed(message), "riverflow": lambda: handle_riverFlow(message, message_from_id, deviceID), "rlist": lambda: handle_repeaterQuery(message_from_id, deviceID, channel_number), - "satpass": lambda: handle_satpass(message_from_id, deviceID, channel_number, message), + "satpass": lambda: handle_satpass(message_from_id, deviceID, message), "setemail": lambda: handle_email(message_from_id, message), "setsms": lambda: handle_sms( message_from_id, message), "sitrep": lambda: handle_lheard(message, message_from_id, deviceID, isDM), "sms:": lambda: handle_sms(message_from_id, message), "solar": lambda: drap_xray_conditions() + "\n" + solar_conditions(), "sun": lambda: handle_sun(message_from_id, deviceID, channel_number), - "sysinfo": lambda: sysinfo(message, message_from_id, deviceID), + "survey": lambda: surveyHandler(message, message_from_id, deviceID), + "s:": lambda: surveyHandler(message, message_from_id, deviceID), + "sysinfo": lambda: sysinfo(message, message_from_id, deviceID, isDM), "test": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "testing": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), + "tictactoe": lambda: handleTicTacToe(message, message_from_id, deviceID), + "tic-tac-toe": lambda: handleTicTacToe(message, message_from_id, deviceID), "tide": lambda: handle_tide(message_from_id, deviceID, channel_number), "valert": lambda: get_volcano_usgs(), "videopoker": lambda: handleVideoPoker(message, message_from_id, deviceID), @@ -91,10 +103,10 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "whoami": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus), "whois": lambda: handle_whois(message, deviceID, channel_number, message_from_id), "wiki:": lambda: handle_wiki(message, isDM), - "wiki?": lambda: handle_wiki(message, isDM), "wx": lambda: handle_wxc(message_from_id, deviceID, 'wx'), "wxa": lambda: handle_wxalert(message_from_id, deviceID, message), "wxalert": lambda: handle_wxalert(message_from_id, deviceID, message), + "x:": lambda: handleShellCmd(message, message_from_id, channel_number, isDM, deviceID), "wxc": lambda: handle_wxc(message_from_id, deviceID, 'wxc'), "๐Ÿ“": lambda: handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus), "๐Ÿ””": lambda: handle_alertBell(message_from_id, deviceID, message), @@ -131,20 +143,25 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n if len(cmds) > 0: # sort the commands by index value cmds = sorted(cmds, key=lambda k: k['index']) - logger.debug(f"System: Bot detected Commands:{cmds} From: {get_name_from_number(message_from_id)}") - # check the command isnt a isDM only command - if cmds[0]['cmd'] in restrictedCommands and not isDM: - bot_response = restrictedResponse + + # Check if user is already playing a game + playing, game = isPlayingGame(message_from_id)[0], isPlayingGame(message_from_id)[1] + + # Block restricted commands if not DM + if (cmds[0]['cmd'] in restrictedCommands and not isDM) or (cmds[0]['cmd'] in restrictedCommands and playing) or playing: + logger.debug(f"System: Bot restricted Command:{cmds[0]['cmd']} From: {get_name_from_number(message_from_id)} isDM:{isDM} playing:{playing}") + if playing: + bot_response = f"๐Ÿค–You are already playing {game}, finish that first." + else: + bot_response = restrictedResponse else: + logger.debug(f"System: Bot detected Commands:{cmds} From: {get_name_from_number(message_from_id)} isDM:{isDM} playing:{playing}") # run the first command after sorting bot_response = command_handler[cmds[0]['cmd']]() # append the command to the cmdHistory list for lheard and history if len(cmdHistory) > 50: cmdHistory.pop(0) cmdHistory.append({'nodeID': message_from_id, 'cmd': cmds[0]['cmd'], 'time': time.time()}) - - # wait a responseDelay to avoid message collision from lora-ack - time.sleep(responseDelay) return bot_response def handle_cmd(message, message_from_id, deviceID): @@ -153,6 +170,59 @@ def handle_cmd(message, message_from_id, deviceID): if " " in message and message.split(" ")[1] in trap_list: return "๐Ÿค– just use the commands directly in chat" return help_message + +def isPlayingGame(message_from_id): + global gameTrackers + trackers = gameTrackers.copy() + playingGame = False + game = "None" + + trackers = [tracker for tracker in trackers if tracker is not None] + + for tracker, game_name, handle_game_func in trackers: + for i in range(len(tracker)-1, -1, -1): # iterate backwards for safe removal + id_key = 'userID' if game_name == "DopeWars" else 'nodeID' + id_key = 'id' if game_name == "Survey" else id_key + if tracker[i].get(id_key) == message_from_id: + last_played_key = 'last_played' if 'last_played' in tracker[i] else 'time' + if tracker[i].get(last_played_key, 0) > (time.time() - GAMEDELAY): + playingGame = True + game = game_name + break + if playingGame: + break + + return playingGame, game + +def checkPlayingGame(message_from_id, message_string, rxNode, channel_number): + global gameTrackers + trackers = gameTrackers.copy() + playingGame = False + game = "None" + + trackers = [tracker for tracker in trackers if tracker is not None] + + for tracker, game_name, handle_game_func in trackers: + playingGame, game = check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func) + if playingGame: + break + return playingGame + +def check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func): + global llm_enabled + + for i in range(len(tracker)): + # Use 'userID' for DopeWars, 'nodeID' for others (including Survey) + id_key = 'userID' if game_name == "DopeWars" else 'nodeID' + + if tracker[i].get(id_key) == message_from_id: + last_played_key = 'last_played' if 'last_played' in tracker[i] else 'time' + if tracker[i].get(last_played_key) > (time.time() - GAMEDELAY): + if llm_enabled: + logger.debug(f"System: LLM Disabled for {message_from_id} for duration of {game_name}") + send_message(handle_game_func(message_string, message_from_id, rxNode), channel_number, message_from_id, rxNode) + return True, game_name + return False, "None" def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number): global multiPing @@ -181,10 +251,13 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann else: msg = "๐Ÿ”Š Can you hear me now?" - if hop == "Direct": - msg = msg + f"SNR:{snr} RSSI:{rssi}" - else: - msg = msg + hop + # append SNR/RSSI or hop info + if hop.startswith("Direct?") and (snr != 0 or rssi != 0): + msg += f"? SNR:{snr} RSSI:{rssi}" + elif hop.startswith("Direct"): + msg += f"SNR:{snr} RSSI:{rssi}" + elif hop: + msg += f"{hop}" if "@" in message: msg = msg + " @" + message.split("@")[1] @@ -225,6 +298,7 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann if pingCount > 1: multiPingList.append({'message_from_id': message_from_id, 'count': pingCount + 1, 'type': type, 'deviceID': deviceID, 'channel_number': channel_number, 'startCount': pingCount}) + logger.info(f"System: Starting auto-ping of type {type} for {pingCount} pings to {get_name_from_number(message_from_id, 'short', deviceID)}") if type == "๐ŸŽ™TEST": msg = f"๐Ÿ›œInitalizing BufferTest, using chunks of about {int(maxBuffer // pingCount)}, max length {maxBuffer} in {pingCount} messages" else: @@ -256,45 +330,56 @@ def handle_emergency(message_from_id, deviceID, message): 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]}" msg = f"๐Ÿ””๐ŸšจIntercepted Possible Emergency Assistance needed for: {nodeInfo}" # alert the emergency_responder_alert_channel - time.sleep(responseDelay) send_message(msg, emergency_responder_alert_channel, 0, emergency_responder_alert_interface) logger.warning(f"System: {message_from_id} Emergency Assistance Requested in {message}") # send the message out via email/sms if enableSMTP: for user in sysopEmails: send_email(user, f"Emergency Assistance Requested by {nodeInfo} in {message}", message_from_id) - # respond to the user - time.sleep(responseDelay + 2) return EMERGENCY_RESPONSE def handle_motd(message, message_from_id, isDM): global MOTD - isAdmin = False - msg = "" - # check if the message_from_id is in the bbs_admin_list - if bbs_admin_list != ['']: - for admin in bbs_admin_list: - if str(message_from_id) == admin: - isAdmin = True - break - else: - isAdmin = True - - # admin help via DM - if "?" in message and isDM and isAdmin: + msg = MOTD + isAdmin = isNodeAdmin(message_from_id) + if "?" in message: msg = "Message of the day, set with 'motd $ HelloWorld!'" - elif "?" in message and isDM and not isAdmin: - # non-admin help via DM - msg = "Message of the day" elif "$" in message and isAdmin: motd = message.split("$")[1] MOTD = motd.rstrip() - logger.debug(f"System: {message_from_id} changed MOTD: {MOTD}") + logger.debug(f"System: {message_from_id} temporarly changed MOTD: {MOTD}") msg = "MOTD changed to: " + MOTD - else: - msg = "MOTD: " + MOTD return msg +def handle_echo(message, message_from_id, deviceID, isDM, channel_number): + + echoBinary = False + if echoBinary: + try: + #send_raw_bytes echo the data to the channel with synch word: + port_num = 256 + synch_word = b"echo:" + message = message.split("echo ")[1] + raw_bytes = synch_word + message.encode('utf-8') + send_raw_bytes(message_from_id, raw_bytes, nodeInt=deviceID, channel=channel_number, portnum=port_num) + except Exception as e: + logger.error(f"System: Echo Exception {e}") + return f"Sent binary echo message to {message_from_id} to {port_num} on channel {channel_number} device {deviceID}" + + if "?" in message.lower(): + return "command returns your message back to you. Example:echo Hello World" + elif "echo " in message.lower(): + parts = message.lower().split("echo ", 1) + if len(parts) > 1 and parts[1].strip() != "": + echo_msg = parts[1] + if channel_number != echoChannel and not isDM: + echo_msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + echo_msg + return echo_msg + else: + return "Please provide a message to echo back to you. Example:echo Hello World" + else: + return "Please provide a message to echo back to you. Example:echo Hello World" + def handle_wxalert(message_from_id, deviceID, message): if use_meteo_wxApi: return "wxalert is not supported" @@ -310,15 +395,97 @@ def handle_wxalert(message_from_id, deviceID, message): weatherAlert = weatherAlert[0] return weatherAlert +def handleNews(message_from_id, deviceID, message, isDM): + news = '' + # if news source is provided pass that to read_news() + if "?" in message.lower(): + return "returns the news. Add a source e.g. ๐Ÿ“ฐreadnews mesh" + elif "readnews" in message.lower(): + source = message.lower().replace("readnews", "").strip() + if source: + news = read_news(source) + else: + news = read_news() + + if news: + # if not a DM add the username to the beginning of msg + if not useDMForResponse and not isDM: + news = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + news + return news + else: + return "No news for you!" + +def handle_howfar(message, message_from_id, deviceID, isDM): + msg = '' + location = get_node_location(message_from_id, deviceID) + lat = location[0] + lon = location[1] + # if ? in message + if "?" in message.lower(): + return "command returns the distance you have traveled since your last HowFar-command. Add 'reset' to reset your starting point." + + # if no GPS location return + if lat == latitudeValue and lon == longitudeValue: + logger.debug(f"System: HowFar: No GPS location for {message_from_id}") + return "No GPS location available" + + if "reset" in message.lower(): + msg = distance(lat,lon,message_from_id, reset=True) + else: + msg = distance(lat,lon,message_from_id) + + # if not a DM add the username to the beginning of msg + if not useDMForResponse and not isDM: + msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + msg + + return msg + +def handle_howtall(message, message_from_id, deviceID, isDM): + msg = '' + location = get_node_location(message_from_id, deviceID) + lat = location[0] + lon = location[1] + if lat == latitudeValue and lon == longitudeValue: + # add guessing tot he msg + msg += "Guessing:" + if use_metric: + measure = "meters" + else: + measure = "feet" + # if ? in message + if "?" in message.lower(): + return f"command estimates your height based on the shadow length you provide in {measure}. Example: howtall 5.5" + # get the shadow length from the message split after howtall + try: + shadow_length = float(message.lower().split("howtall ")[1].split(" ")[0]) + except: + return f"Please provide a shadow length in {measure} example: howtall 5.5" + + # get data + msg += measureHeight(lat, lon, shadow_length) + + # if data has NO_ALERTS return help + if NO_ALERTS in msg: + return f"Please provide a shadow length in {measure} example: howtall 5.5" + + return msg + def handle_wiki(message, isDM): # location = get_node_location(message_from_id, deviceID) msg = "Wikipedia search function. \nUsage example:๐Ÿ“ฒwiki: travelling gnome" - if "wiki:" in message.lower(): - search = message.split(":")[1] - search = search.strip() - if search: - return get_wikipedia_summary(search) - return "Please add a search term example:๐Ÿ“ฒwiki: travelling gnome" + try: + if "wiki:?" in message.lower() or "wiki: ?" in message.lower() or "wiki?" in message.lower() or "wiki ?" in message.lower(): + return msg + if "wiki" in message.lower(): + search = message.split(":")[1] + search = search.strip() + if search: + return get_wikipedia_summary(search) + return "Please add a search term example:๐Ÿ“ฒwiki: travelling gnome" + except Exception as e: + logger.error(f"System: Wiki Exception {e}") + msg = "Error processing your request" + return msg # Runtime Variables for LLM @@ -326,8 +493,12 @@ llmRunCounter = 0 llmTotalRuntime = [] llmLocationTable = [{'nodeID': 1234567890, 'location': 'No Location'},] -def handle_satpass(message_from_id, deviceID, channel_number, message): - location = get_node_location(message_from_id, deviceID) +def handle_satpass(message_from_id, deviceID, message='', vox=False): + if vox: + location = (latitudeValue, longitudeValue) + message = 'satpass' + else: + location = get_node_location(message_from_id, deviceID) passes = '' satList = satListConfig message = message.lower() @@ -388,11 +559,9 @@ def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel if (channel_number == publicChannel and antiSpam) or useDMForResponse: # send via DM send_message(welcome_message, channel_number, message_from_id, deviceID) - time.sleep(responseDelay) else: # send via channel send_message(welcome_message, channel_number, 0, deviceID) - time.sleep(responseDelay) # mark the node as welcomed for node in seenNodes: if node['nodeID'] == message_from_id: @@ -426,7 +595,6 @@ def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel else: # send via channel send_message(msg, channel_number, 0, deviceID) - time.sleep(responseDelay) start = time.time() @@ -441,33 +609,49 @@ def handle_llm(message_from_id, channel_number, deviceID, message, publicChannel return response def handleDopeWars(message, nodeID, rxNode): - global dwPlayerTracker, dwHighScore - - # get player's last command - last_cmd = None - for i in range(0, len(dwPlayerTracker)): - if dwPlayerTracker[i].get('userID') == nodeID: - last_cmd = dwPlayerTracker[i].get('cmd') - - # welcome new player - if not last_cmd and nodeID != 0: + from modules.settings import dwPlayerTracker + global dwHighScore + + # Find player in tracker + player = next((p for p in dwPlayerTracker if p.get('userID') == nodeID), None) + + # If not found, add new player + if not player and nodeID != 0 and not isPlayingGame(nodeID)[0]: + player = { + 'userID': nodeID, + 'last_played': time.time(), + 'cmd': 'new', + # ... add other fields as needed ... + } + dwPlayerTracker.append(player) msg = 'Welcome to ๐Ÿ’ŠDope Wars๐Ÿ’‰ You have ' + str(total_days) + ' days to make as much ๐Ÿ’ฐ as possible! ' high_score = getHighScoreDw() - msg += 'The High Score is $' + "{:,}".format(high_score.get('cash')) + ' by user ' + get_name_from_number(high_score.get('userID') , 'short', rxNode) +'\n' + msg += 'The High Score is $' + "{:,}".format(high_score.get('cash')) + ' by user ' + get_name_from_number(high_score.get('userID'), 'short', rxNode) + '\n' msg += playDopeWars(nodeID, message) - else: - logger.debug(f"System: {nodeID} PlayingGame dopewars last_cmd: {last_cmd}") + elif player: + # Update last_played and cmd for the player + for p in dwPlayerTracker: + if p.get('userID') == nodeID: + p['last_played'] = time.time() msg = playDopeWars(nodeID, message) - # wait a second to keep from message collision - time.sleep(responseDelay + 1) + + # if message starts wth 'e'xit remove player from tracker + if message.lower().startswith('e'): + dwPlayerTracker[:] = [p for p in dwPlayerTracker if p.get('userID') != nodeID] + msg = 'You have exited Dope Wars.' return msg -def handle_gTnW(): +def handle_gTnW(chess = False): + 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??"] response = ["The only winning move is not to play.", "What are you doing, Dave?",\ "Greetings, Professor Falken.", "Shall we play a game?", "How about a nice game of chess?",\ "You are a hard man to reach. Could not find you in Seattle and no terminal is in operation at your classified address.",\ "I should reach Defcon 1 and release my missiles in 28 hours.","T-minus thirty","Malfunction 54: Treatment pause;dose input 2", "reticulating splines"] length = len(response) + chess_length = len(chess) + if chess: + response = chess + length = chess_length indices = list(range(length)) # Shuffle the indices using a convoluted method for i in range(length): @@ -478,125 +662,146 @@ def handle_gTnW(): return response[selected_index] def handleLemonade(message, nodeID, deviceID): - global lemonadeTracker, lemonadeCups, lemonadeLemons, lemonadeSugar, lemonadeWeeks, lemonadeScore, lemon_starting_cash, lemon_total_weeks + from modules.settings import lemonadeTracker + global lemonadeCups, lemonadeLemons, lemonadeSugar, lemonadeWeeks, lemonadeScore, lemon_starting_cash, lemon_total_weeks msg = "" + def create_player(nodeID): # create new player - logger.debug("System: Lemonade: New Player: " + str(nodeID)) - lemonadeTracker.append({'nodeID': nodeID, 'cups': 0, 'lemons': 0, 'sugar': 0, 'cash': lemon_starting_cash, 'start': lemon_starting_cash, 'cmd': 'new', 'time': time.time()}) + lemonadeTracker.append({'nodeID': nodeID, 'cups': 0, 'lemons': 0, 'sugar': 0, 'cash': lemon_starting_cash, 'start': lemon_starting_cash, 'cmd': 'new', 'last_played': time.time()}) lemonadeCups.append({'nodeID': nodeID, 'cost': 2.50, 'count': 25, 'min': 0.99, 'unit': 0.00}) lemonadeLemons.append({'nodeID': nodeID, 'cost': 4.00, 'count': 8, 'min': 2.00, 'unit': 0.00}) lemonadeSugar.append({'nodeID': nodeID, 'cost': 3.00, 'count': 15, 'min': 1.50, 'unit': 0.00}) lemonadeScore.append({'nodeID': nodeID, 'value': 0.00, 'total': 0.00}) lemonadeWeeks.append({'nodeID': nodeID, 'current': 1, 'total': lemon_total_weeks, 'sales': 99, 'potential': 0, 'unit': 0.00, 'price': 0.00, 'total_sales': 0}) - - # get player's last command from tracker if not new player - last_cmd = "" - for i in range(len(lemonadeTracker)): - if lemonadeTracker[i]['nodeID'] == nodeID: - last_cmd = lemonadeTracker[i]['cmd'] - logger.debug(f"System: {nodeID} PlayingGame lemonstand last_cmd: {last_cmd}") - # create new player if not in tracker - if last_cmd == "" and nodeID != 0: + # If player not found, create if message is for lemonstand + if nodeID != 0 and "lemonstand" in message.lower(): create_player(nodeID) msg += "Welcome๐Ÿ‹๐Ÿฅค" + # Play lemonstand with newgame=True + fruit = playLemonstand(nodeID=nodeID, message=message, celsius=False, newgame=True) + if fruit: + msg += fruit + return msg - # high score - highScore = {"userID": 0, "cash": 0, "success": 0} + # if message starts wth 'e'xit remove player from tracker + if message.lower().startswith("e"): + logger.debug(f"System: Lemonade: {nodeID} is leaving the stand") + msg = "You have left the Lemonade Stand." highScore = getHighScoreLemon() - if highScore != 0: - if highScore['userID'] != 0: - nodeName = get_name_from_number(highScore['userID']) - if nodeName.isnumeric() and multiple_interface: - logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}") - #nodeName = get_name_from_number(highScore['userID'], 'long', 2) - msg += f" HighScore๐Ÿฅ‡{nodeName} ๐Ÿ’ฐ{round(highScore['cash'], 2)}k " - - msg += start_lemonade(nodeID=nodeID, message=message, celsius=False) - # wait a second to keep from message collision - time.sleep(responseDelay + 1) + if highScore != 0 and highScore['userID'] != 0: + nodeName = get_name_from_number(highScore['userID']) + msg += f" HighScore๐Ÿฅ‡{nodeName} ๐Ÿ’ฐ{round(highScore['cash'], 2)}k " + # remove player from player tracker and inventory trackers + lemonadeTracker[:] = [p for p in lemonadeTracker if p['nodeID'] != nodeID] + lemonadeCups[:] = [p for p in lemonadeCups if p['nodeID'] != nodeID] + lemonadeLemons[:] = [p for p in lemonadeLemons if p['nodeID'] != nodeID] + lemonadeSugar[:] = [p for p in lemonadeSugar if p['nodeID'] != nodeID] + lemonadeWeeks[:] = [p for p in lemonadeWeeks if p['nodeID'] != nodeID] + lemonadeScore[:] = [p for p in lemonadeScore if p['nodeID'] != nodeID] + return msg + + # play lemonstand (not newgame) + if ("lemonstand" not in message.lower() and message != ""): + fruit = playLemonstand(nodeID=nodeID, message=message, celsius=False, newgame=False) + if fruit: + msg += fruit return msg def handleBlackJack(message, nodeID, deviceID): - global jackTracker + from modules.settings import jackTracker msg = "" - # get player's last command from tracker + # Find player in tracker + player = next((p for p in jackTracker if p['nodeID'] == nodeID), None) + + # Handle leave command + if message.lower().startswith("l"): + logger.debug(f"System: BlackJack: {nodeID} is leaving the table") + msg = "You have left the table." + jackTracker[:] = [p for p in jackTracker if p['nodeID'] != nodeID] + return msg + + # Create new player if not found + if not player and nodeID != 0: + logger.debug(f"System: BlackJack: New Player {nodeID}") + # create new player + jackTracker.append({ + 'nodeID': nodeID, + 'bet': 0, + 'cash': 100, # starting cash + 'gameStats': {'p_win': 0, 'd_win': 0, 'draw': 0}, + 'p_cards': [], + 'd_cards': [], + 'p_hand': [], + 'd_hand': [], + 'next_card': [], + 'last_played': time.time(), + 'cmd': 'new' + }) + msg += f"Welcome to ๐ŸƒBlackJack๐Ÿƒ!\n (H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table" + # Show high score if available + highScore = 0 + highScore = loadHSJack() + if highScore and highScore.get('nodeID', 0) != 0: + nodeName = get_name_from_number(highScore['nodeID']) + if nodeName.isnumeric() and multiple_interface: + logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}") + msg += f" HighScore๐Ÿฅ‡{nodeName} with {highScore['highScore']} chips. " + player = next((p for p in jackTracker if p['nodeID'] == nodeID), None) + + # Always update last_played for existing player + if player: + player['last_played'] = time.time() + + # get player's last command from tracker if not new player last_cmd = "" for i in range(len(jackTracker)): if jackTracker[i]['nodeID'] == nodeID: last_cmd = jackTracker[i]['cmd'] - # if player sends a L for leave table - if message.lower().startswith("l"): - logger.debug(f"System: BlackJack: {nodeID} is leaving the table") - msg = "You have left the table." - for i in range(len(jackTracker)): - if jackTracker[i]['nodeID'] == nodeID: - jackTracker.pop(i) - return msg - - else: - # Play BlackJack - msg = playBlackJack(nodeID=nodeID, message=message) - - if last_cmd != "" and nodeID != 0: - logger.debug(f"System: {nodeID} PlayingGame blackjack last_cmd: {last_cmd}") - else: - highScore = {'nodeID': 0, 'highScore': 0} - highScore = loadHSJack() - if highScore != 0: - if highScore['nodeID'] != 0: - nodeName = get_name_from_number(highScore['nodeID']) - if nodeName.isnumeric() and multiple_interface: - logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}") - #nodeName = get_name_from_number(highScore['nodeID'], 'long', 2) - msg += f" HighScore๐Ÿฅ‡{nodeName} with {highScore['highScore']} chips. " - time.sleep(responseDelay + 1) # short answers with long replies can cause message collision added wait + # Play BlackJack + msg += playBlackJack(nodeID=nodeID, message=message, last_cmd=last_cmd) return msg def handleVideoPoker(message, nodeID, deviceID): - global vpTracker + from modules.settings import vpTracker msg = "" - # if player sends a L for leave table + # Find player in tracker + player = next((p for p in vpTracker if p['nodeID'] == nodeID), None) + + # Handle leave command if message.lower().startswith("l"): logger.debug(f"System: VideoPoker: {nodeID} is leaving the table") msg = "You have left the table." - for i in range(len(vpTracker)): - if vpTracker[i]['nodeID'] == nodeID: - vpTracker.pop(i) + vpTracker[:] = [p for p in vpTracker if p['nodeID'] != nodeID] return msg - else: - # Play Video Poker - msg = playVideoPoker(nodeID=nodeID, message=message) - # get player's last command from tracker - last_cmd = "" - for i in range(len(vpTracker)): - if vpTracker[i]['nodeID'] == nodeID: - last_cmd = vpTracker[i]['cmd'] + # Create new player if not found + if not player and nodeID != 0: + vpTracker.append({'nodeID': nodeID, 'cmd': 'new', 'last_played': time.time()}) + msg += "Welcome to ๐ŸŽฐVideo Poker!๐ŸŽฐ\n" + # Show high score if available + highScore = loadHSVp() + if highScore and highScore.get('nodeID', 0) != 0: + nodeName = get_name_from_number(highScore['nodeID']) + if nodeName.isnumeric() and multiple_interface: + logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}") + msg += f" HighScore๐Ÿฅ‡{nodeName} with {highScore['highScore']} coins. " + player = next((p for p in vpTracker if p['nodeID'] == nodeID), None) - # find higest dollar amount in tracker for high score - if last_cmd == "new": - highScore = {'nodeID': 0, 'highScore': 0} - highScore = loadHSVp() - if highScore != 0: - if highScore['nodeID'] != 0: - nodeName = get_name_from_number(highScore['nodeID']) - if nodeName.isnumeric() and multiple_interface: - logger.debug(f"System: TODO is multiple interface fix mention this please nodeName: {nodeName}") - #nodeName = get_name_from_number(highScore['nodeID'], 'long', 2) - msg += f" HighScore๐Ÿฅ‡{nodeName} with {highScore['highScore']} coins. " - - if last_cmd != "" and nodeID != 0: - logger.debug(f"System: {nodeID} PlayingGame videopoker last_cmd: {last_cmd}") - time.sleep(responseDelay + 1) # short answers with long replies can cause message collision added wait + # Always update last_played for existing player + if player: + player['last_played'] = time.time() + + # Play Video Poker + msg += playVideoPoker(nodeID=nodeID, message=message) return msg def handleMmind(message, nodeID, deviceID): - global mindTracker + from modules.settings import mindTracker msg = '' if "end" in message.lower() or message.lower().startswith("e"): @@ -605,10 +810,18 @@ def handleMmind(message, nodeID, deviceID): for i in range(len(mindTracker)): if mindTracker[i]['nodeID'] == nodeID: mindTracker.pop(i) - highscore = getHighScoreMMind(0, 0, 'n') - if highscore != 0: - nodeName = get_name_from_number(highscore[0]['nodeID'],'long',deviceID) - msg += f"๐Ÿง HighScore๐Ÿฅ‡{nodeName} with {highscore[0]['turns']} turns difficulty {highscore[0]['diff'].upper()}" + hscore = getHighScoreMMind(0, 0, 'n') + if hscore and isinstance(hscore[0], dict): + highNode = hscore[0].get('nodeID', 0) + highTurns = hscore[0].get('turns', 0) + highDiff = hscore[0].get('diff', 'n') + else: + highNode = 0 + highTurns = 0 + highDiff = 'n' + nodeName = get_name_from_number(int(highNode),'long',deviceID) + if highNode != 0 and highTurns > 1: + msg += f"๐Ÿง HighScore๐Ÿฅ‡{nodeName} with {highTurns} turns difficulty {highDiff}" return msg # get player's last command from tracker if not new player @@ -629,16 +842,33 @@ def handleMmind(message, nodeID, deviceID): return msg msg += start_mMind(nodeID=nodeID, message=message) - # wait a second to keep from message collision - time.sleep(responseDelay + 1) return msg def handleGolf(message, nodeID, deviceID): - global golfTracker + from modules.settings import golfTracker msg = '' # get player's last command from tracker if not new player last_cmd = "" + + # Ensure player exists in tracker + if not any(entry['nodeID'] == nodeID for entry in golfTracker): + logger.debug("System: GolfSim: New Player: " + str(nodeID)) + golfTracker.append({ + 'nodeID': nodeID, + 'last_played': time.time(), + 'cmd': 'new', + 'hole': 1, + 'distance_remaining': 0, + 'hole_shots': 0, + 'hole_strokes': 0, + 'hole_to_par': 0, + 'total_strokes': 0, + 'total_to_par': 0, + 'par': 0, + 'hazard': '' + }) + # get player's last command from tracker for i in range(len(golfTracker)): if golfTracker[i]['nodeID'] == nodeID: last_cmd = golfTracker[i]['cmd'] @@ -653,20 +883,17 @@ def handleGolf(message, nodeID, deviceID): logger.debug(f"System: {nodeID} PlayingGame golfsim last_cmd: {last_cmd}") - if last_cmd == "" and nodeID != 0: + if last_cmd == "new" and nodeID != 0: # create new player - logger.debug("System: GolfSim: New Player: " + str(nodeID)) - golfTracker.append({'nodeID': nodeID, 'last_played': time.time(), 'cmd': 'new', 'hole': 1, 'distance_remaining': 0, 'hole_shots': 0, 'hole_strokes': 0, 'hole_to_par': 0, 'total_strokes': 0, 'total_to_par': 0, 'par': 0, 'hazard': ''}) + msg = f"Welcome to ๐ŸŒ๏ธGolfSimโ›ณ๏ธ\n" - msg += f"Clubs: (D)river, (L)ow Iron, (M)id Iron, (H)igh Iron, (G)ap Wedge, Lob (W)edge\n" + msg += f"Clubs: (D)river, (L)ow Iron, (M)id Iron, (H)igh Iron, (G)ap Wedge, Lob (W)edge (C)addie\n" - msg += playGolf(nodeID=nodeID, message=message) - # wait a second to keep from message collision - time.sleep(responseDelay + 1) + msg += playGolf(nodeID=nodeID, message=message, last_cmd=last_cmd) return msg def handleHangman(message, nodeID, deviceID): - global hangmanTracker + from modules.settings import hangmanTracker index = 0 msg = '' for i in range(len(hangmanTracker)): @@ -689,12 +916,10 @@ def handleHangman(message, nodeID, deviceID): ) msg = "๐ŸงฉHangman๐Ÿค– 'end' to cut rope๐Ÿชข\n" msg += hangman.play(nodeID, message) - - time.sleep(responseDelay + 1) return msg def handleHamtest(message, nodeID, deviceID): - global hamtestTracker + from modules.settings import hamtestTracker index = 0 msg = '' response = message.split(' ') @@ -724,38 +949,182 @@ def handleHamtest(message, nodeID, deviceID): # if the message is an answer A B C or D upper or lower case if response[0].upper() in ['A', 'B', 'C', 'D']: msg = hamtest.answer(nodeID, response[0]) - - time.sleep(responseDelay + 1) return msg -def handle_riverFlow(message, message_from_id, deviceID): - location = get_node_location(message_from_id, deviceID) - userRiver = message.lower() +def handleTicTacToe(message, nodeID, deviceID): + from modules.settings import tictactoeTracker + index = 0 + msg = '' - if "riverflow " in userRiver: - userRiver = userRiver.split("riverflow ")[1] if "riverflow " in userRiver else riverListDefault + # Find or create player tracker entry + for i in range(len(tictactoeTracker)): + if tictactoeTracker[i]['nodeID'] == nodeID: + tictactoeTracker[i]["last_played"] = time.time() + index = i+1 + break + + if message.lower().startswith('e'): + if index: + tictactoe.end(nodeID) + tictactoeTracker.pop(index-1) + return "Thanks for playing! ๐ŸŽฏ" + + if not index: + tictactoeTracker.append({ + "nodeID": nodeID, + "last_played": time.time() + }) + msg = "๐ŸŽฏTic-Tac-Toe๐Ÿค– '(e)nd'\n" + + msg += tictactoe.play(nodeID, message) + return msg + +def quizHandler(message, nodeID, deviceID): + user_name = get_name_from_number(nodeID) + user_id = nodeID + msg = '' + user_answer = '' + user_answer = message.lower() + user_answer = user_answer.replace("quiz","").replace("q:","").strip() + if user_answer.startswith("!") and cmdBang: + user_answer = user_answer[1:].strip() + if user_answer: + if user_answer.startswith("start"): + msg = quizGamePlayer.start_game(user_id) + elif user_answer.startswith("stop"): + msg = quizGamePlayer.stop_game(user_id) + elif user_answer.startswith("join"): + msg = quizGamePlayer.join(user_id) + elif user_answer.startswith("leave"): + msg = quizGamePlayer.leave(user_id) + elif user_answer.startswith("next"): + msg = quizGamePlayer.next_question(user_id) + elif user_answer.startswith("score"): + if user_id in quizGamePlayer.players: + score = quizGamePlayer.players[user_id]['score'] + msg = f"Your score: {score}" + else: + msg = "You are not in the quiz." + elif user_answer.startswith("top"): + msg = quizGamePlayer.top_three() + elif user_answer.startswith("broadcast"): + broadcast_msg = user_answer.replace("broadcast", "", 1).strip() + msg = quizGamePlayer.broadcast(user_id, broadcast_msg) + elif user_answer.startswith("?"): + msg = ("Quiz Commands:\n" + "q: join - Join the current quiz\n" + "q: leave - Leave the current quiz\n" + "q: - Answer the current question\n" + "q: score - Show your current score\n" + "q: top - Show top 3 players\n") + else: + msg = quizGamePlayer.answer(user_id, user_answer) + + # set username on top 3 + if "๐Ÿ† Top" in msg: + #replace all the 10 digit numbers with the short name + for part in msg.split(): + part = part.rstrip(":") + if len(part) == 10: + player_name = get_name_from_number(int(part), 'short', deviceID) + msg = msg.replace(part, player_name) + + # broadcast message to all players if user is in bbs_admin_list and msg is a dict with 'message' key + if isinstance(msg, dict) and str(nodeID) in bbs_admin_list and 'message' in msg: + for player_id in quizGamePlayer.players: + send_message(msg['message'], 0, player_id, deviceID) + msg = f"Message sent to {len(quizGamePlayer.players)} players" + + return msg else: - userRiver = userRiver.split(",") if "," in userRiver else riverListDefault + return "๐Ÿง Please provide an answer or command, or send q: ?" + +def surveyHandler(message, nodeID, deviceID): + user_id = nodeID + location = get_node_location(nodeID, deviceID) + msg = '' + # Normalize and parse the command + msg_lower = message.lower().strip() + surveySays = msg_lower + if msg_lower.startswith("survey"): + surveySays = surveySays.removeprefix("survey").strip() + elif msg_lower.startswith("s:"): + surveySays = surveySays.removeprefix("s:").strip() - # return river flow data + # Handle end command + if surveySays == "end": + if nodeID not in survey_module.responses: + return "No active survey session to end." + return survey_module.end_survey(user_id=nodeID) + + # Handle report command + if 'report' in surveySays: + if str(nodeID) not in bbs_admin_list: + return "You do not have permission to view survey reports." + # remove the words 'survey' and 'report' from the message + report = msg_lower.replace("survey", "").replace("report", "").strip() + results = survey_module.get_survey_results(survey_name=report if report else None) + return survey_module.format_survey_results(results) + + # Update last played or add new tracker entry + found = False + for entry in surveyTracker: + if entry.get('nodeID') == nodeID: + entry['last_played'] = time.time() + found = True + break + if not found: + surveyTracker.append({'nodeID': nodeID, 'last_played': time.time()}) + + # If not in survey session, start one + if nodeID not in survey_module.responses: + msg = survey_module.start_survey(user_id=nodeID, survey_name=surveySays, location=location) + else: + # Process the answer + msg = survey_module.answer(user_id=nodeID, answer=surveySays, location=location) + + return msg + +def handle_riverFlow(message, message_from_id, deviceID, vox=False): + # River Flow from NOAA or Open-Meteo + if vox: + location = (latitudeValue, longitudeValue) + message = "riverflow" + else: + location = get_node_location(message_from_id, deviceID) + msg_lower = message.lower() + if "riverflow " in msg_lower: + user_input = msg_lower.split("riverflow ", 1)[1].strip() + if user_input: + userRiver = [r.strip() for r in user_input.split(",") if r.strip()] + else: + userRiver = riverListDefault + else: + userRiver = riverListDefault + if use_meteo_wxApi: return get_flood_openmeteo(location[0], location[1]) else: - # if userRiver a list - if type(userRiver) == list: - msg = "" - for river in userRiver: - msg += get_flood_noaa(location[0], location[1], river) - return msg - # if single river - msg = get_flood_noaa(location[0], location[1], userRiver) + msg = "" + for river in userRiver: + msg += get_flood_noaa(location[0], location[1], river) return msg def handle_mwx(message_from_id, deviceID, cmd): - # NOAA Coastal and Marine Weather PZZ - return get_nws_marine(zone=pzzZoneID, days=pzzForecastDays) + # NOAA Coastal and Marine Weather + if myCoastalZone is None: + logger.warning("System: Coastal Zone not set, please set in config.ini") + return NO_ALERTS + return get_nws_marine(zone=myCoastalZone, days=coastalForecastDays) -def handle_wxc(message_from_id, deviceID, cmd): +def handle_wxc(message_from_id, deviceID, cmd, vox=False): + # Weather from NOAA or Open-Meteo + if vox: + # return a default message if vox is enabled + if use_meteo_wxApi: + return get_wx_meteo(latitudeValue, longitudeValue) + else: + return get_NOAAweather(latitudeValue, longitudeValue) location = get_node_location(message_from_id, deviceID) if use_meteo_wxApi and not "wxc" in cmd and not use_metric: #logger.debug("System: Bot Returning Open-Meteo API for weather imperial") @@ -782,6 +1151,11 @@ def handle_emergency_alerts(message, message_from_id, deviceID): else: # Headlines only FEMA return getIpawsAlert(str(location[0]), str(location[1]), shortAlerts=True) + +def handleEarthquake(message, message_from_id, deviceID): + location = get_node_location(message_from_id, deviceID) + if "earthquake" in message.lower(): + return checkUSGSEarthQuake(str(location[0]), str(location[1])) def handle_checklist(message, message_from_id, deviceID): name = get_name_from_number(message_from_id, 'short', deviceID) @@ -840,32 +1214,71 @@ def handle_messages(message, deviceID, channel_number, msg_history, publicChanne if "?" in message and isDM: return message.split("?")[0].title() + " command returns the last " + str(storeFlimit) + " messages sent on a channel." else: - response = "" - for msgH in msg_history: - if msgH[4] == deviceID: - if msgH[2] == channel_number or msgH[2] == publicChannel: - response += f"\n{msgH[0]}: {msgH[1]}" - if len(response) > 0: - return "Message History:" + response - else: - return "No messages in history" + # Filter messages for this device/channel + filtered_msgs = [ + msgH for msgH in msg_history + if msgH[4] == deviceID and (msgH[2] == channel_number or msgH[2] == publicChannel) + ] + + # Choose order and slice + # Oldest first, take first N + filtered_msgs = filtered_msgs[-storeFlimit:][::-1] + if reverseSF: + # reverse that + filtered_msgs = filtered_msgs[::-1] -def handle_sun(message_from_id, deviceID, channel_number): + response = "" + header = f"๐Ÿ“จMsgs:\n" + for msgH in filtered_msgs: + new_line = f"\n{msgH[0]}: {msgH[1]}" + test_response = response + new_line + if len(test_response.encode('utf-8')) > maxBuffer: + # Truncate message if needed + msg_text = msgH[1] + truncated = False + trunc_marker = "..." + while len(msg_text) > 0 and len((response + f"\n{msgH[0]}: {msg_text}{trunc_marker}").encode('utf-8')) > maxBuffer: + msg_text = msg_text[:-1] + truncated = True + if len(msg_text) > 10: + if truncated: + response += f"\n{msgH[0]}: {msg_text}{trunc_marker}" + else: + response += f"\n{msgH[0]}: {msg_text}" + break + continue + else: + response += new_line + + if len(response) > 0: + return header + response + else: + return "No ๐Ÿ“ญmessages in history" + +def handle_sun(message_from_id, deviceID, channel_number, vox=False): + if vox: + # return a default message if vox is enabled + return get_sun(str(latitudeValue), str(longitudeValue)) location = get_node_location(message_from_id, deviceID, channel_number) return get_sun(str(location[0]), str(location[1])) -def sysinfo(message, message_from_id, deviceID): +def sysinfo(message, message_from_id, deviceID, isDM): if "?" in message: return "sysinfo command returns system information." else: if enable_runShellCmd and file_monitor_enabled: # get the system information from the shell script # this is an example of how to run a shell script and return the data - shellData = call_external_script(None, "script/sysEnv.sh") + shellData = call_external_script('', "script/sysEnv.sh") # check if the script returned data if shellData == "" or shellData == None: # no data returned from the script shellData = "shell script data missing" + # if not an admin remove any line in the shellData that had 'IP:' in it + if (str(message_from_id) not in bbs_admin_list) or (not isDM): + shell_lines = shellData.splitlines() + filtered_lines = [line for line in shell_lines if 'IP:' not in line] + shellData = "\n".join(filtered_lines) return get_sysinfo(message_from_id, deviceID) + "\n" + shellData.rstrip() else: return get_sysinfo(message_from_id, deviceID) @@ -958,15 +1371,18 @@ def handle_repeaterQuery(message_from_id, deviceID, channel_number): else: return "Repeater lookup not enabled" -def handle_tide(message_from_id, deviceID, channel_number): +def handle_tide(message_from_id, deviceID, channel_number, vox=False): + if vox: + return get_NOAAtide(str(latitudeValue), str(longitudeValue)) location = get_node_location(message_from_id, deviceID, channel_number) return get_NOAAtide(str(location[0]), str(location[1])) -def handle_moon(message_from_id, deviceID, channel_number): +def handle_moon(message_from_id, deviceID, channel_number, vox=False): + if vox: + return get_moon(str(latitudeValue), str(longitudeValue)) location = get_node_location(message_from_id, deviceID, channel_number) return get_moon(str(location[0]), str(location[1])) - def handle_whoami(message_from_id, deviceID, hop, snr, rssi, pkiStatus): try: loc = [] @@ -1031,62 +1447,24 @@ def handle_whois(message, deviceID, channel_number, message_from_id): msg += f"Loc: {where_am_i(str(location[0]), str(location[1]))}" return msg -def check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func): - global llm_enabled - - for i in range(len(tracker)): - if tracker[i].get('nodeID') == message_from_id or tracker[i].get('userID') == message_from_id: - last_played_key = 'last_played' if 'last_played' in tracker[i] else 'time' - if tracker[i].get(last_played_key) > (time.time() - GAMEDELAY): - if llm_enabled: - logger.debug(f"System: LLM Disabled for {message_from_id} for duration of {game_name}") - - # play the game - send_message(handle_game_func(message_string, message_from_id, rxNode), channel_number, message_from_id, rxNode) - return True, game_name - else: - # pop if the time exceeds 8 hours - tracker.pop(i) - return False, game_name - return False, "None" - -def checkPlayingGame(message_from_id, message_string, rxNode, channel_number): - playingGame = False - game = "None" - - trackers = [ - (dwPlayerTracker, "DopeWars", handleDopeWars) if 'dwPlayerTracker' in globals() else None, - (lemonadeTracker, "LemonadeStand", handleLemonade) if 'lemonadeTracker' in globals() else None, - (vpTracker, "VideoPoker", handleVideoPoker) if 'vpTracker' in globals() else None, - (jackTracker, "BlackJack", handleBlackJack) if 'jackTracker' in globals() else None, - (mindTracker, "MasterMind", handleMmind) if 'mindTracker' in globals() else None, - (golfTracker, "GolfSim", handleGolf) if 'golfTracker' in globals() else None, - (hangmanTracker, "Hangman", handleHangman) if 'hangmanTracker' in globals() else None, - (hamtestTracker, "HamTest", handleHamtest) if 'hamtestTracker' in globals() else None, - ] - trackers = [tracker for tracker in trackers if tracker is not None] - - for tracker, game_name, handle_game_func in trackers: - playingGame, game = check_and_play_game(tracker, message_from_id, message_string, rxNode, channel_number, game_name, handle_game_func) - if playingGame: - break - - return playingGame - def onReceive(packet, interface): - global seenNodes + global seenNodes, msg_history, cmdHistory # Priocess the incoming packet, handles the responses to the packet with auto_response() # Sends the packet to the correct handler for processing # extract interface details from inbound packet rxType = type(interface).__name__ - # Valies assinged to the packet - rxNode, message_from_id, snr, rssi, hop, hop_away, channel_number = 0, 0, 0, 0, 0, 0, 0 + # Values assinged to the packet + rxNode = message_from_id = snr = rssi = hop = hop_away = channel_number = hop_start = hop_count = hop_limit = 0 pkiStatus = (False, 'ABC') + rxNodeHostName = None replyIDset = False emojiSeen = False + simulator_flag = False isDM = False + channel_name = "unknown" + session_passkey = None playingGame = False if DEBUGpacket: @@ -1096,44 +1474,60 @@ def onReceive(packet, interface): # Debug print the packet for debugging logger.debug(f"Packet Received\n {packet} \n END of packet \n") - # set the value for the incomming interface - if rxType == 'SerialInterface': - rxInterface = interface.__dict__.get('devPath', 'unknown') - if port1 in rxInterface: rxNode = 1 - elif multiple_interface and port2 in rxInterface: rxNode = 2 - elif multiple_interface and port3 in rxInterface: rxNode = 3 - elif multiple_interface and port4 in rxInterface: rxNode = 4 - elif multiple_interface and port5 in rxInterface: rxNode = 5 - elif multiple_interface and port6 in rxInterface: rxNode = 6 - elif multiple_interface and port7 in rxInterface: rxNode = 7 - elif multiple_interface and port8 in rxInterface: rxNode = 8 - elif multiple_interface and port9 in rxInterface: rxNode = 9 - + # determine the rxNode based on the interface type if rxType == 'TCPInterface': rxHost = interface.__dict__.get('hostname', 'unknown') - if rxHost and hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1 - elif multiple_interface and rxHost and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2 - elif multiple_interface and rxHost and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3 - elif multiple_interface and rxHost and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4 - elif multiple_interface and rxHost and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5 - elif multiple_interface and rxHost and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6 - elif multiple_interface and rxHost and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7 - elif multiple_interface and rxHost and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8 - elif multiple_interface and rxHost and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9 - if rxType == 'BLEInterface': - if interface1_type == 'ble': rxNode = 1 - elif multiple_interface and interface2_type == 'ble': rxNode = 2 - elif multiple_interface and interface3_type == 'ble': rxNode = 3 - elif multiple_interface and interface4_type == 'ble': rxNode = 4 - elif multiple_interface and interface5_type == 'ble': rxNode = 5 - elif multiple_interface and interface6_type == 'ble': rxNode = 6 - elif multiple_interface and interface7_type == 'ble': rxNode = 7 - elif multiple_interface and interface8_type == 'ble': rxNode = 8 - elif multiple_interface and interface9_type == 'ble': rxNode = 9 + rxNodeHostName = interface.__dict__.get('ip', None) + rxNode = next( + (i for i in range(1, 10) + if multiple_interface and rxHost and + globals().get(f'hostname{i}', '').split(':', 1)[0] in rxHost and + globals().get(f'interface{i}_type', '') == 'tcp'),None) + + if rxType == 'SerialInterface': + rxInterface = interface.__dict__.get('devPath', 'unknown') + rxNode = next( + (i for i in range(1, 10) + if globals().get(f'port{i}', '') in rxInterface),None) - # check if the packet has a channel flag use it + if rxType == 'BLEInterface': + rxNode = next( + (i for i in range(1, 10) + if globals().get(f'interface{i}_type', '') == 'ble'),0) + + if rxNode is None: + # default to interface 1 ## FIXME needs better like a default interface setting or hash lookup + if 'decoded' in packet and packet['decoded']['portnum'] in ['ADMIN_APP', 'SIMULATOR_APP']: + session_passkey = packet.get('decoded', {}).get('admin', {}).get('sessionPasskey', None) + rxNode = 1 + + # check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup if packet.get('channel'): - channel_number = packet.get('channel', 0) + channel_number = packet.get('channel') + # get channel name from channel number from connected devices + for device in channel_list: + if device["interface_id"] == rxNode: + device_channels = device['channels'] + for chan_name, info in device_channels.items(): + if info['number'] == channel_number: + channel_name = chan_name + break + + # get channel hashes for the interface + device = next((d for d in channel_list if d["interface_id"] == rxNode), None) + if device: + # Find the channel name whose hash matches channel_number + for chan_name, info in device['channels'].items(): + if info['hash'] == channel_number: + print(f"Matched channel hash {info['hash']} to channel name {chan_name}") + channel_name = chan_name + break + + # check if the packet has a simulator flag + simulator_flag = packet.get('decoded', {}).get('simulator', False) + if isinstance(simulator_flag, dict): + # assume Software Simulator + simulator_flag = True # set the message_from_id message_from_id = packet['from'] @@ -1147,22 +1541,29 @@ def onReceive(packet, interface): msg = bbs_check_dm(message_from_id) if msg: - # wait a responseDelay to avoid message collision from lora-ack. - time.sleep(responseDelay) - logger.info(f"System: BBS DM Found: {msg[1]} For: {get_name_from_number(message_from_id, 'long', rxNode)}") + logger.info(f"System: BBS DM Delivery: {msg[1]} For: {get_name_from_number(message_from_id, 'long', rxNode)}") message = "Mail: " + msg[1] + " From: " + get_name_from_number(msg[2], 'long', rxNode) bbs_delete_dm(msg[0], msg[1]) send_message(message, channel_number, message_from_id, rxNode) - + # handle TEXT_MESSAGE_APP try: if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': message_bytes = packet['decoded']['payload'] message_string = message_bytes.decode('utf-8') + via_mqtt = packet['decoded'].get('viaMqtt', False) + transport_mechanism = ( + packet.get('transport_mechanism') + or packet.get('transportMechanism') + or (packet.get('decoded', {}).get('transport_mechanism')) + or (packet.get('decoded', {}).get('transportMechanism')) + or 'unknown' + ) + rx_time = packet['decoded'].get('rxTime', time.time()) # check if the packet is from us if message_from_id in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]: - logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay deteted") + logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay detected") # get the signal strength and snr if available if packet.get('rxSnr') or packet.get('rxRssi'): @@ -1184,39 +1585,45 @@ def onReceive(packet, interface): # check if the packet has a hop count flag use it if packet.get('hopsAway'): hop_away = packet.get('hopsAway', 0) - else: - # if the packet does not have a hop count try other methods - if packet.get('hopLimit'): - hop_limit = packet.get('hopLimit', 0) - else: - hop_limit = 0 - - if packet.get('hopStart'): - hop_start = packet.get('hopStart', 0) - else: - hop_start = 0 - - if enableHopLogs: - logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start}") - if hop_away == 0 and hop_limit == 0 and hop_start == 0: - logger.debug(f"System: Packet HopDebugger: No hop count found in PACKET {packet} END PACKET") - - if hop_start == hop_limit: - hop = "Direct" - hop_count = 0 - elif hop_start == 0 and hop_limit > 0: - hop = "MQTT" - hop_count = 0 - else: - # set hop to Direct if the message was sent directly otherwise set the hop count - if hop_away > 0: - hop_count = hop_away - else: - hop_count = hop_start - hop_limit - #print (f"calculated hop count: {hop_start} - {hop_limit} = {hop_count}") - hop = f"{hop_count} hops" + if packet.get('hopStart'): + hop_start = packet.get('hopStart', 0) + + if packet.get('hopLimit'): + hop_limit = packet.get('hopLimit', 0) + # calculate hop count + hop = "" + if hop_limit > 0 and hop_start >= hop_limit: + hop_count = hop_away + (hop_start - hop_limit) + elif hop_limit > 0 and hop_start < hop_limit: + hop_count = hop_away + (hop_limit - hop_start) + else: + hop_count = hop_away + + if hop == "" and hop_count > 0: + hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops" + + if hop_start == hop_limit and "lora" in str(transport_mechanism).lower(): + hop = "Direct" + + if ((hop_start == 0 and hop_limit >= 0) or via_mqtt or ("mqtt" in str(transport_mechanism).lower())): + hop = "MQTT" + + ## FIXME should this be here? + if hop == "" and hop_count ==0 and (snr != 0 or rssi != 0): + hop = "Direct?" + + if "unknown" in str(transport_mechanism).lower() and (snr == 0 and rssi == 0): + hop = "IP-Network" + + if enableHopLogs: + 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}") + + # check with stringSafeChecker if the message is safe + if stringSafeCheck(message_string) is False: + logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}") + if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string: # ignore help and welcome messages logger.warning(f"Got Own Welcome/Help header. From: {get_name_from_number(message_from_id, 'long', rxNode)}") @@ -1235,22 +1642,22 @@ def onReceive(packet, interface): send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, message_from_id, rxNode) else: # DM is useful for games or LLM - if games_enabled and (hop == "Direct" or hop_count < game_hop_limit): + if games_enabled and ("Direct" in hop or hop_count < game_hop_limit): playingGame = checkPlayingGame(message_from_id, message_string, rxNode, channel_number) - else: + elif hop_count >= game_hop_limit: if games_enabled: 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}") send_message(f"Your hop count exceeds safe playable distance at {hop_count} hops", channel_number, message_from_id, rxNode) - time.sleep(responseDelay) else: playingGame = False + else: + playingGame = False if not playingGame: if llm_enabled and llmReplyToNonCommands: # respond with LLM llm = handle_llm(message_from_id, channel_number, rxNode, message_string, publicChannel) send_message(llm, channel_number, message_from_id, rxNode) - time.sleep(responseDelay) 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)}") @@ -1259,7 +1666,6 @@ def onReceive(packet, interface): if not any(node['nodeID'] == message_from_id and node['welcome'] == True for node in seenNodes): # send welcome message send_message(welcome_message, channel_number, message_from_id, rxNode) - time.sleep(responseDelay) # mark the node as welcomed for node in seenNodes: if node['nodeID'] == message_from_id: @@ -1271,9 +1677,7 @@ def onReceive(packet, interface): else: # respond with help message on DM send_message(help_message, channel_number, message_from_id, rxNode) - - time.sleep(responseDelay) - + # log the message to the message log if log_messages_to_file: msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-')) @@ -1315,12 +1719,14 @@ def onReceive(packet, interface): timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") else: timestamp = datetime.now().strftime("%Y-%m-%d %I:%M:%S%p") - - if len(msg_history) < storeFlimit: - msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode)) - else: - msg_history.pop(0) - msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode)) + + # trim the history list if it exceeds max_history + if len(msg_history) >= MAX_MSG_HISTORY: + # Always keep only the most recent MAX_MSG_HISTORY entries + msg_history = msg_history[-MAX_MSG_HISTORY:] + + # add the message to the history list + msg_history.append((get_name_from_number(message_from_id, 'long', rxNode), message_string, channel_number, timestamp, rxNode)) # print the message to the log and sdout logger.info(f"Device:{rxNode} Channel:{channel_number} " + CustomFormatter.green + "Ignoring Message:" + CustomFormatter.white +\ @@ -1332,14 +1738,17 @@ def onReceive(packet, interface): if repeater_enabled and multiple_interface: # wait a responseDelay to avoid message collision from lora-ack. time.sleep(responseDelay) - rMsg = (f"{message_string} From:{get_name_from_number(message_from_id, 'short', rxNode)}") - # if channel found in the repeater list repeat the message - if str(channel_number) in repeater_channels: - for i in range(1, 10): - if globals().get(f'interface{i}_enabled', False) and i != rxNode: - logger.debug(f"Repeating message on Device{i} Channel:{channel_number}") - send_message(rMsg, channel_number, 0, i) - time.sleep(responseDelay) + if len(message_string) > (3 * MESSAGE_CHUNK_SIZE): + logger.warning(f"System: Not repeating message, exceeds size limit ({len(message_string)} > {3 * MESSAGE_CHUNK_SIZE})") + else: + rMsg = (f"{message_string} From:{get_name_from_number(message_from_id, 'short', rxNode)}") + # if channel found in the repeater list repeat the message + if str(channel_number) in repeater_channels: + for i in range(1, 10): + if globals().get(f'interface{i}_enabled', False) and i != rxNode: + logger.debug(f"Repeating message on Device{i} Channel:{channel_number}") + send_message(rMsg, channel_number, 0, i) + time.sleep(responseDelay) # if QRZ enabled check if we have said hello if qrz_hello_enabled: @@ -1353,12 +1762,10 @@ def onReceive(packet, interface): hello(message_from_id, name) # send a hello message as a DM if not train_qrz: - time.sleep(responseDelay) send_message(f"Hello {name} {qrz_hello_string}", channel_number, message_from_id, rxNode) - time.sleep(responseDelay) else: # Evaluate non TEXT_MESSAGE_APP packets - consumeMetadata(packet, rxNode) + consumeMetadata(packet, rxNode, channel_number) except KeyError as e: logger.critical(f"System: Error processing packet: {e} Device:{rxNode}") logger.debug(f"System: Error Packet = {packet}") @@ -1382,10 +1789,14 @@ async def start_rx(): if "trouble" not in llmLoad: logger.debug(f"System: LLM Model {llmModel} loaded") + if useDMForResponse: + logger.debug("System: Respond by DM only") + if log_messages_to_file: logger.debug("System: Logging Messages to disk") if syslog_to_file: logger.debug("System: Logging System Logs to disk") + if bbs_enabled: logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}") if bbs_link_enabled: @@ -1393,167 +1804,181 @@ async def start_rx(): logger.debug(f"System: BBS Link Enabled with {len(bbs_link_whitelist)} peers") else: logger.debug(f"System: BBS Link Enabled allowing all") + if solar_conditions_enabled: logger.debug("System: Celestial Telemetry Enabled") + if location_enabled: if use_meteo_wxApi: logger.debug("System: Location Telemetry Enabled using Open-Meteo API") else: logger.debug("System: Location Telemetry Enabled using NOAA API") + if dad_jokes_enabled: logger.debug("System: Dad Jokes Enabled!") - if pzzEnabled: - logger.debug("Coastal Forcast and Tide Enabled!") + + if coastalEnabled: + logger.debug("System: Coastal Forecast and Tide Enabled!") + if games_enabled: logger.debug("System: Games Enabled!") + if wikipedia_enabled: - logger.debug("System: Wikipedia search Enabled") + if use_kiwix_server: + logger.debug(f"System: Wikipedia search Enabled using Kiwix server at {kiwix_url}") + else: + logger.debug("System: Wikipedia search Enabled") + + if rssEnable: + logger.debug(f"System: RSS Feed Reader Enabled for feeds: {rssFeedNames}") + if motd_enabled: - logger.debug(f"System: MOTD Enabled using {MOTD}") + logger.debug(f"System: MOTD Enabled using {MOTD} scheduler:{schedulerMotd}") + if sentry_enabled: - logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel}") + logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel} requestLOC:{reqLocationEnabled}") + if sentryIgnoreList: + logger.debug(f"System: Sentry BlockList Enabled for nodes: {sentryIgnoreList}") + if sentryWatchList: + logger.debug(f"System: Sentry WatchList Enabled for nodes: {sentryWatchList}") + if highfly_enabled: logger.debug(f"System: HighFly Enabled using {highfly_altitude}m limit reporting to channel:{highfly_channel}") + if store_forward_enabled: - logger.debug(f"System: Store and Forward Enabled using limit: {storeFlimit}") - if useDMForResponse: - logger.debug(f"System: Respond by DM only") + logger.debug(f"System: S&F(messages command) Enabled using limit: {storeFlimit} and reverse queue:{reverseSF}") + + if enableEcho: + logger.debug("System: Echo command Enabled") + if repeater_enabled and multiple_interface: logger.debug(f"System: Repeater Enabled for Channels: {repeater_channels}") + if radio_detection_enabled: - logger.debug(f"System: Radio Detection Enabled using rigctld at {rigControlServerAddress} brodcasting to channels: {sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}") + logger.debug(f"System: Radio Detection Enabled using rigctld at {rigControlServerAddress} broadcasting to channels: {sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}") + if file_monitor_enabled: - logger.debug(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}") - if enable_runShellCmd: - logger.debug(f"System: Shell Command monitor enabled") - if read_news_enabled: - logger.debug(f"System: File Monitor News Reader Enabled for {news_file_path}") - if bee_enabled: - logger.debug(f"System: File Monitor Bee Monitor Enabled for bee.txt") + logger.warning(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}") + if enable_runShellCmd: + logger.debug("System: Shell Command monitor enabled") + if allowXcmd: + logger.warning("System: File Monitor shell XCMD Enabled") + if read_news_enabled: + logger.debug(f"System: File Monitor News Reader Enabled for {news_file_path}") + if bee_enabled: + logger.debug("System: File Monitor Bee Monitor Enabled for bee.txt") + if wxAlertBroadcastEnabled: logger.debug(f"System: Weather Alert Broadcast Enabled on channels {wxAlertBroadcastChannel}") + if emergencyAlertBrodcastEnabled: logger.debug(f"System: Emergency Alert Broadcast Enabled on channels {emergencyAlertBroadcastCh} for FIPS codes {myStateFIPSList}") - # check if the FIPS codes are set if myStateFIPSList == ['']: - logger.warning(f"System: No FIPS codes set for iPAWS Alerts") + logger.warning("System: No FIPS codes set for iPAWS Alerts") + if emergency_responder_enabled: logger.debug(f"System: Emergency Responder Enabled on channels {emergency_responder_alert_channel} for interface {emergency_responder_alert_interface}") + if volcanoAlertBroadcastEnabled: logger.debug(f"System: Volcano Alert Broadcast Enabled on channels {volcanoAlertBroadcastChannel}") - if qrz_hello_enabled and train_qrz: - logger.debug(f"System: QRZ Welcome/Hello Enabled with training mode") - if qrz_hello_enabled and not train_qrz: - logger.debug(f"System: QRZ Welcome/Hello Enabled") + + if qrz_hello_enabled: + if train_qrz: + logger.debug("System: QRZ Welcome/Hello Enabled with training mode") + else: + logger.debug("System: QRZ Welcome/Hello Enabled") + if checklist_enabled: - logger.debug(f"System: CheckList Module Enabled") - if ignoreChannels != []: + logger.debug("System: CheckList Module Enabled") + + if ignoreChannels: logger.debug(f"System: Ignoring Channels: {ignoreChannels}") + + if noisyNodeLogging: + logger.debug("System: Noisy Node Logging Enabled") + + if logMetaStats: + logger.debug("System: Logging Metadata Stats Enabled, leaderboard") + loadLeaderboard() + if enableSMTP: if enableImap: - logger.debug(f"System: SMTP Email Alerting Enabled using IMAP") + logger.debug("System: SMTP Email Alerting Enabled using IMAP") else: - logger.debug(f"System: SMTP Email Alerting Enabled") + logger.warning("System: SMTP Email Alerting Enabled") + if scheduler_enabled: - # Reminder Scheduler is enabled every Monday at noon send a log message - schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Enabled Reminder")) - - # basic scheduler - if schedulerValue != '': - logger.debug(f"System: Starting the broadcast scheduler from config.ini") - if schedulerValue.lower() == 'day': - if schedulerTime != '': - # Send a message every day at the time set in schedulerTime - schedule.every().day.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - else: - # Send a message every day at the time set in schedulerInterval - schedule.every(int(schedulerInterval)).days.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'mon' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Monday at the time set in schedulerTime - schedule.every().monday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'tue' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Tuesday at the time set in schedulerTime - schedule.every().tuesday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'wed' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Wednesday at the time set in schedulerTime - schedule.every().wednesday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'thu' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Thursday at the time set in schedulerTime - schedule.every().thursday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'fri' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Friday at the time set in schedulerTime - schedule.every().friday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'sat' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Saturday at the time set in schedulerTime - schedule.every().saturday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'sun' in schedulerValue.lower() and schedulerTime != '': - # Send a message every Sunday at the time set in schedulerTime - schedule.every().sunday.at(schedulerTime).do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'hour' in schedulerValue.lower(): - # Send a message every hour at the time set in schedulerTime - schedule.every(int(schedulerInterval)).hours.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - elif 'min' in schedulerValue.lower(): - # Send a message every minute at the time set in schedulerTime - schedule.every(int(schedulerInterval)).minutes.do(lambda: send_message(schedulerMessage, schedulerChannel, 0, schedulerInterface)) - else: - logger.debug(f"System: Starting the broadcast scheduler") - - # Enhanced Examples of using the scheduler, Times here are in 24hr format - # https://schedule.readthedocs.io/en/stable/ - - # Good Morning Every day at 09:00 using send_message function to channel 2 on device 1 - #schedule.every().day.at("09:00").do(lambda: send_message("Good Morning", 2, 0, 1)) - - # Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1 - #schedule.every().day.at("08:00").do(lambda: send_message(handle_wxc(0, 1, 'wx'), 2, 0, 1)) - - # Send Weather Channel Notice Wed. Noon on channel 2, device 1 - #schedule.every().wednesday.at("12:00").do(lambda: send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", 2, 0, 1)) - - # Send config URL for Medium Fast Network Use every other day at 10:00 to default channel 2 on device 1 - #schedule.every(2).days.at("10:00").do(lambda: send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", 2, 0, 1)) - - # Send a Net Starting Now Message Every Wednesday at 19:00 using send_message function to channel 2 on device 1 - #schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now", 2, 0, 1)) - - # Send a Welcome Notice for group on the 15th and 25th of the month at 12:00 using send_message function to channel 2 on device 1 - #schedule.every().day.at("12:00").do(lambda: send_message("Welcome to the group", 2, 0, 1)).day(15, 25) - - # Send a joke every 6 hours using tell_joke function to channel 2 on device 1 - #schedule.every(6).hours.do(lambda: send_message(tell_joke(), 2, 0, 1)) - - # Send a joke every 2 minutes using tell_joke function to channel 2 on device 1 - #schedule.every(2).minutes.do(lambda: send_message(tell_joke(), 2, 0, 1)) - - # Send the Welcome Message every other day at 08:00 using send_message function to channel 2 on device 1 - #schedule.every(2).days.at("08:00").do(lambda: send_message(welcome_message, 2, 0, 1)) - - # Send the MOTD every day at 13:00 using send_message function to channel 2 on device 1 - #schedule.every().day.at("13:00").do(lambda: send_message(MOTD, 2, 0, 1)) - - # Send bbslink looking for peers every other day at 10:00 using send_message function to channel 3 on device 1 - #schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", 3, 0, 1)) - await BroadcastScheduler() + # setup the scheduler + from modules.scheduler import setup_scheduler + await setup_scheduler( + schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface, + schedulerValue, schedulerTime, schedulerInterval, logger, BroadcastScheduler + ) # here we go loopty loo while True: await asyncio.sleep(0.5) pass + +# Initialize game trackers +gameTrackers = [ + (dwPlayerTracker, "DopeWars", handleDopeWars) if 'dwPlayerTracker' in globals() else None, + (lemonadeTracker, "LemonadeStand", handleLemonade) if 'lemonadeTracker' in globals() else None, + (vpTracker, "VideoPoker", handleVideoPoker) if 'vpTracker' in globals() else None, + (jackTracker, "BlackJack", handleBlackJack) if 'jackTracker' in globals() else None, + (mindTracker, "MasterMind", handleMmind) if 'mindTracker' in globals() else None, + (golfTracker, "GolfSim", handleGolf) if 'golfTracker' in globals() else None, + (hangmanTracker, "Hangman", handleHangman) if 'hangmanTracker' in globals() else None, + (hamtestTracker, "HamTest", handleHamtest) if 'hamtestTracker' in globals() else None, + (tictactoeTracker, "TicTacToe", handleTicTacToe) if 'tictactoeTracker' in globals() else None, + (surveyTracker, "Survey", surveyHandler) if 'surveyTracker' in globals() else None, + #quiz does not use a tracker (quizGamePlayer) always active +] + # Hello World async def main(): - meshRxTask = asyncio.create_task(start_rx()) - watchdogTask = asyncio.create_task(watchdog()) - if file_monitor_enabled: - fileMonTask: asyncio.Task = asyncio.create_task(handleFileWatcher()) - if radio_detection_enabled: - hamlibTask = asyncio.create_task(handleSignalWatcher()) + tasks = [] + + try: + # Create core tasks + tasks.append(asyncio.create_task(start_rx(), name="mesh_rx")) + tasks.append(asyncio.create_task(watchdog(), name="watchdog")) + + # Add optional tasks + if file_monitor_enabled: + tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor")) + + if radio_detection_enabled: + tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib")) - await asyncio.gather(meshRxTask, watchdogTask) - if radio_detection_enabled: - await asyncio.gather(hamlibTask) - if file_monitor_enabled: - await asyncio.gather(fileMonTask) + if voxDetectionEnabled: + tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection")) + + logger.debug(f"System: Starting {len(tasks)} async tasks") + + # Wait for all tasks with proper exception handling + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Check for exceptions in results + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"Task {tasks[i].get_name()} failed with: {result}") + + except Exception as e: + logger.error(f"Main loop error: {e}") + finally: + # Cleanup tasks + logger.debug("System: Cleaning up async tasks") + for task in tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + logger.debug(f"Task {task.get_name()} cancelled successfully") + except Exception as e: + logger.warning(f"Error cancelling task {task.get_name()}: {e}") await asyncio.sleep(0.01) diff --git a/modules/bbstools.py b/modules/bbstools.py index b4a2d97..3b54962 100644 --- a/modules/bbstools.py +++ b/modules/bbstools.py @@ -4,6 +4,13 @@ import pickle # pip install pickle from modules.log import * import time +from datetime import datetime + +useSynchCompression = False + +if useSynchCompression: + import zlib + from modules.system import send_raw_bytes trap_list_bbs = ("bbslist", "bbspost", "bbsread", "bbsdelete", "bbshelp", "bbsinfo", "bbslink", "bbsack") @@ -11,24 +18,43 @@ trap_list_bbs = ("bbslist", "bbspost", "bbsread", "bbsdelete", "bbshelp", "bbsin bbs_messages = [] bbs_dm = [] + def load_bbsdb(): global bbs_messages # load the bbs messages from the database file try: with open('data/bbsdb.pkl', 'rb') as f: - bbs_messages = pickle.load(f) - except Exception as e: + new_bbs_messages = pickle.load(f) + if isinstance(new_bbs_messages, list): + for msg in new_bbs_messages: + #example [1, 'Welcome to meshBBS', 'Welcome to the BBS, please post a message!', 0] + msgHash = hash(tuple(msg[1:3])) # Create a hash of the message content (subject and body) + # Check if the message already exists in bbs_messages + if all(hash(tuple(existing_msg[1:3])) != msgHash for existing_msg in bbs_messages): + # if the message is not a duplicate, add it to bbs_messages Maintain the message ID sequence + new_id = len(bbs_messages) + 1 + bbs_messages.append([new_id, msg[1], msg[2], msg[3]]) + except FileNotFoundError: + logger.debug("System: bbsdb.pkl not found, creating new one") + bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]] + try: + with open('data/bbsdb.pkl', 'wb') as f: + pickle.dump(bbs_messages, f) + except Exception as e: + logger.error(f"System: Error creating bbsdb.pkl: {e}") + except Exception as e: + logger.error(f"System: Error loading bbsdb.pkl: {e}") bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]] - logger.debug("System: Creating new data/bbsdb.pkl") - with open('data/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 data/bbsdb.pkl") - with open('data/bbsdb.pkl', 'wb') as f: - pickle.dump(bbs_messages, f) + try: + logger.debug("System: Saving data/bbsdb.pkl") + with open('data/bbsdb.pkl', 'wb') as f: + pickle.dump(bbs_messages, f) + except Exception as e: + logger.error(f"System: Error saving bbsdb: {e}") def bbs_help(): # help message @@ -40,7 +66,7 @@ def bbs_list_messages(): message_list = "" for message in bbs_messages: # message[0] is the messageID, message[1] is the subject - message_list += "Msg #" + str(message[0]) + " " + message[1] + "\n" + message_list += "[#" + str(message[0]) + "] " + message[1] + "\n" # last newline removed message_list = message_list[:-1] @@ -70,7 +96,11 @@ def bbs_delete_message(messageID = 0, fromNode = 0): else: return "Please specify a message number to delete." -def bbs_post_message(subject, message, fromNode): +def bbs_post_message(subject, message, fromNode, threadID=0, replytoID=0): + # post a message to the bbsdb + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + thread = threadID + replyto = replytoID # post a message to the bbsdb and assign a messageID messageID = len(bbs_messages) + 1 @@ -78,15 +108,17 @@ def bbs_post_message(subject, message, fromNode): if str(fromNode) in bbs_ban_list: logger.warning(f"System: Naughty node {fromNode}, tried to post a message: {subject}, {message} and was dropped.") return "Message posted. ID is: " + str(messageID) - + # validate message length isnt three times the MESSAGE_CHUNK_SIZE + if len(message) > (3 * MESSAGE_CHUNK_SIZE): + return "Message too long, max length is " + str(3 * MESSAGE_CHUNK_SIZE) + " characters." # validate not a duplicate message for msg in bbs_messages: if msg[1].strip().lower() == subject.strip().lower() and msg[2].strip().lower() == message.strip().lower(): messageID = msg[0] return "Message posted. ID is: " + str(messageID) - + # validate its not overlength by keeping in chunker limit # append the message to the list - bbs_messages.append([messageID, subject, message, fromNode]) + bbs_messages.append([messageID, subject, message, fromNode, now, thread, replyto]) logger.info(f"System: NEW Message Posted, subject: {subject}, message: {message} from {fromNode}") # save the bbsdb @@ -99,8 +131,10 @@ def bbs_read_message(messageID = 0): if (messageID - 1) >= len(bbs_messages): return "Message not found." if messageID > 0: + fromNode = bbs_messages[messageID - 1][3] + fromNodeHex = hex(fromNode)[-4:] message = bbs_messages[messageID - 1] - return f"Msg #{message[0]}\nMsg Body: {message[2]}" + return f"Msg #{message[0]}\nFrom:{fromNodeHex}\n{message[2]}" else: return "Please specify a message number to read." @@ -116,7 +150,11 @@ def load_bbsdm(): # load the bbs messages from the database file try: with open('data/bbsdm.pkl', 'rb') as f: - bbs_dm = pickle.load(f) + new_bbs_dm = pickle.load(f) + if isinstance(new_bbs_dm, list): + for msg in new_bbs_dm: + if msg not in bbs_dm: + bbs_dm.append(msg) except: bbs_dm = [[1234567890, "Message", 1234567890]] logger.debug("System: Creating new data/bbsdm.pkl") @@ -129,6 +167,14 @@ def bbs_post_dm(toNode, message, fromNode): if str(fromNode) in bbs_ban_list: logger.warning(f"System: Naughty node {fromNode}, tried to post a message: {message} and was dropped.") return "DM Posted for node " + str(toNode) + + # validate message length isnt three times the MESSAGE_CHUNK_SIZE + if len(message) > (3 * MESSAGE_CHUNK_SIZE): + return "Message too long, max length is " + str(3 * MESSAGE_CHUNK_SIZE) + " characters." + # validate not a duplicate message + for msg in bbs_dm: + if msg[0] == int(toNode) and msg[1].strip().lower() == message.strip().lower(): + return "DM Posted for node " + str(toNode) # append the message to the list bbs_dm.append([int(toNode), message, int(fromNode)]) @@ -163,6 +209,32 @@ def bbs_delete_dm(toNode, message): return "System: cleared mail for" + str(toNode) return "System: No DM found for node " + str(toNode) +def compress_data(data_to_compress): + # Prepare message as bytes + compressed = zlib.compress(data_to_compress.encode('utf-8')) + return compressed + +def decompress_data(data_bytes): + try: + decompressed = zlib.decompress(data_bytes) + msg = decompressed.decode('utf-8') + return msg + except Exception as e: + logger.warning(f"Error decompressing data: {e}") + return False + +def bbs_receive_compressed(data_bytes, fromNode, RxNode): + try: + decompressed = zlib.decompress(data_bytes) + msg = decompressed.decode('utf-8') + + bbs_sync_posts(msg, fromNode, RxNode) + + return msg + except Exception as e: + logger.error(f"Error decompressing BBS message: {e}") + return None + def bbs_sync_posts(input, peerNode, RxNode): messageID = 0 @@ -180,7 +252,12 @@ def bbs_sync_posts(input, peerNode, RxNode): #store the message subject = input.split("$")[1].split("#")[0] body = input.split("#")[1] - bbs_post_message(subject, body, peerNode) + fromNodeHex = input.split("@")[1] + try: + bbs_post_message(subject, body, int(fromNodeHex, 16)) + except: + logger.error(f"System: Error parsing bbslink from node {peerNode}: {input}") + fromNodeHex = hex(peerNode) messageID = input.split(" ")[1] return f"bbsack {messageID}" elif "bbsack" in input.lower(): @@ -195,12 +272,20 @@ def bbs_sync_posts(input, peerNode, RxNode): # send message with delay to keep chutil happy if messageID < len(bbs_messages): - logger.debug(f"System: Sending bbslink message {messageID} to peer " + str(peerNode)) + logger.debug(f"System: wait to bbslink with peer " + str(peerNode)) + fromNodeHex = hex(bbs_messages[messageID][3]) time.sleep(5 + responseDelay) # every 5 messages add extra delay if messageID % 5 == 0: time.sleep(10 + responseDelay) - return f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]}" + logger.debug(f"System: Sending bbslink message {messageID} of {len(bbs_messages)} to peer " + str(peerNode)) + msg = f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]} @{fromNodeHex}" + if useSynchCompression: + compressed = compress_data(msg) + send_raw_bytes(peerNode, compressed) + logger.debug("System: Sent compressed bbslink message to peer " + str(peerNode)) + else: + return msg else: logger.debug("System: bbslink sync complete with peer " + str(peerNode)) diff --git a/modules/checklist.py b/modules/checklist.py index 8948bfe..6f3a1f4 100644 --- a/modules/checklist.py +++ b/modules/checklist.py @@ -122,8 +122,16 @@ def list_checkin(): timeCheckedIn = "" checkin_list = "" for row in rows: - #calculate length of time checked in - timeCheckedIn = time.strftime("%H:%M:%S", time.gmtime(time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S")))) + # Calculate length of time checked in, including days + total_seconds = time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S")) + days = int(total_seconds // 86400) + hours = int((total_seconds % 86400) // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + if days > 0: + timeCheckedIn = f"{days}d {hours:02}:{minutes:02}:{seconds:02}" + else: + timeCheckedIn = f"{hours:02}:{minutes:02}:{seconds:02}" checkin_list += "ID: " + row[1] + " checked-In for " + timeCheckedIn if row[5] != "": checkin_list += "๐Ÿ“" + row[5] @@ -154,6 +162,17 @@ def process_checklist_command(nodeID, message, name="none", location="none"): return delete_checkin(nodeID) elif "purgeout" in message.lower(): return delete_checkout(nodeID) + elif "?" in message.lower(): + if not reverse_in_out: + return ("Command: checklist followed by\n" + "checkout to check out\n" + "purgeout to delete your checkout record\n" + "Example: checkin Arrived at park") + else: + return ("Command: checklist followed by\n" + "checkin to check out\n" + "purgeout to delete your checkin record\n" + "Example: checkout Leaving park") elif "checklist" in message.lower(): return list_checkin() else: diff --git a/modules/filemon.py b/modules/filemon.py index 6bd9abf..66a3ae1 100644 --- a/modules/filemon.py +++ b/modules/filemon.py @@ -5,14 +5,17 @@ from modules.log import * import asyncio import random import os +import subprocess +from datetime import datetime, timedelta trap_list_filemon = ("readnews",) -def read_file(file_monitor_file_path, random_line_only=False): +NEWS_DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') +newsSourcesList = [] +def read_file(file_monitor_file_path, random_line_only=False): try: if not os.path.exists(file_monitor_file_path): - logger.warning(f"FileMon: File not found: {file_monitor_file_path}") if file_monitor_file_path == "bee.txt": return "๐Ÿbuzz ๐Ÿ’buzz buzz๐Ÿฏ" if random_line_only: @@ -28,25 +31,29 @@ def read_file(file_monitor_file_path, random_line_only=False): except Exception as e: logger.warning(f"FileMon: Error reading file: {file_monitor_file_path}") return None - -def read_news(): - # read the news file on demand - return read_file(news_file_path, news_random_line_only) +def read_news(source=None): + # Reads the news file. If a source is provided, reads {source}_news.txt. + if source: + file_path = os.path.join(NEWS_DATA_DIR, f"{source}_news.txt") + else: + file_path = os.path.join(NEWS_DATA_DIR, news_file_path) + return read_file(file_path, news_random_line_only) def write_news(content, append=False): # write the news file on demand try: - with open(news_file_path, 'a' if append else 'w', encoding='utf-8') as f: - f.write(content) - logger.info(f"FileMon: Updated {news_file_path}") + file_path = os.path.join(NEWS_DATA_DIR, news_file_path) + with open(file_path, 'a' if append else 'w', encoding='utf-8') as f: + #f.write(content) + logger.info(f"FileMon: Updated {file_path}") return True except Exception as e: - logger.warning(f"FileMon: Error writing file: {news_file_path}") + logger.warning(f"FileMon: Error writing file: {file_path}") return False async def watch_file(): - + # Watch the file for changes and return the new content when it changes if not os.path.exists(file_monitor_file_path): return None else: @@ -63,22 +70,114 @@ async def watch_file(): return content await asyncio.sleep(1) # Check every -def call_external_script(message, script="script/runShell.sh"): +def call_external_script(message, script="runShell.sh"): + # If no path is given, assume script/ directory + if "/" not in script and "\\" not in script: + script = os.path.join("script", script) try: - # Debugging: Print the current working directory and resolved script path current_working_directory = os.getcwd() script_path = os.path.join(current_working_directory, script) if not os.path.exists(script_path): - # try the raw script name + # Try the raw script name script_path = script if not os.path.exists(script_path): logger.warning(f"FileMon: Script not found: {script_path}") return "sorry I can't do that" - - output = os.popen(f"bash {script_path} {message}").read().encode('utf-8').decode('utf-8') - return output + + result = subprocess.run( + ["bash", script_path, message], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + logger.error(f"FileMon: Script error: {result.stderr.strip()}") + return None + + output = result.stdout.strip() + return output if output else None except Exception as e: logger.warning(f"FileMon: Error calling external script: {e}") return None - \ No newline at end of file + + +waitingXroom = {} # {message_from_id: (expected_answer, original_command, timestamp)} +def handleShellCmd(message, message_from_id, channel_number, isDM, deviceID): + if not allowXcmd: + return "x: command is disabled" + if str(message_from_id) not in bbs_admin_list: + logger.warning(f"FileMon: Unauthorized x: command attempt from {message_from_id}") + return "x: command not authorized" + if not isDM: + return "x: command not authorized in group chat" + + # 2FA logic + if xCmd2factorEnabled: + timeNOW = datetime.utcnow() + # If user is waiting for 2FA, treat message as answer + if message_from_id in waitingXroom: + answer = message[2:].strip() if message.lower().startswith("x:") else message.strip() + expected, orig_command, ts = waitingXroom[message_from_id] + if timeNOW - ts > timedelta(seconds=xCmd2factor_timeout): + del waitingXroom[message_from_id] + return "x2FA timed out, please try again" + if answer == str(expected): + del waitingXroom[message_from_id] + # Run the original command + try: + logger.info(f"FileMon: Running shell command from {message_from_id}: {orig_command}") + result = subprocess.run(orig_command, shell=True, capture_output=True, text=True, timeout=10, start_new_session=True) + output = result.stdout.strip() + return output if output else "โœ… x: processed finished, no output" + except Exception as e: + logger.warning(f"FileMon: Error running shell command: {e}") + logger.debug(f"FileMon: This command is not good for use over the mesh network") + return "x: error running command" + else: + logger.warning(f"FileMon: ๐ŸšจIncorrect 2FA answer from {message_from_id}") + return "x2FA incorrect, try again" + # If not waiting, treat as new command and issue challenge + if message.lower().startswith("x:"): + command = message[2:].strip() + # Generate two random numbers, seed with message_from_id and time of day + seed = timeNOW.second + timeNOW.minute * 60 + timeNOW.hour * 3600 + int(message_from_id) + rnd = random.Random(seed) + a = rnd.randint(10, 99) + b = rnd.randint(10, 99) + expected = a + b + waitingXroom[message_from_id] = (expected, command, timeNOW) + return f"x2FA required.\nReply `x: answer`\nWhat is {a} + {b}? " + else: + return "invalid command format" + + # If we reach here, 2FA is disabled or passed + if enable_runShellCmd: + if message.lower().startswith("x:"): + command = message[2:].strip() + else: + return "invalid command format" + try: + logger.info(f"FileMon: Running shell command from {message_from_id}: {command}") + result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10, start_new_session=True) + output = result.stdout.strip() + return output if output else "x: command executed with no output" + except Exception as e: + logger.warning(f"FileMon: Error running shell command: {e}") + logger.debug(f"FileMon: This command is not good for use over the mesh network") + return "error running command" + else: + logger.debug("FileMon: x: command is disabled by no enable_runShellCmd") + return "command is disabled" + +def initNewsSources(): + #check for the files _news.txt and add to the newsHeadlines list + global newsSourcesList + newsSourcesList = [] + for file in os.listdir(NEWS_DATA_DIR): + if file.endswith('_news.txt'): + source = file[:-9] # remove _news.txt + newsSourcesList.append(source) + +#initialize the headlines on startup +initNewsSources() diff --git a/modules/games/blackjack.py b/modules/games/blackjack.py index 2b8a21e..2c33802 100644 --- a/modules/games/blackjack.py +++ b/modules/games/blackjack.py @@ -7,8 +7,7 @@ import time import pickle jack_starting_cash = 100 # Replace 100 with your desired starting cash value -jackTracker= [{'nodeID': 0, 'cmd': 'new', 'time': time.time(), 'cash': jack_starting_cash,\ - 'bet': 0, 'gameStats': {'p_win': 0, 'd_win': 0, 'draw': 0}, 'p_cards':[], 'd_cards':[], 'p_hand':[], 'd_hand':[], 'next_card':[]}] +from modules.settings import jackTracker SUITS = ("โ™ฅ๏ธ", "โ™ฆ๏ธ", "โ™ ๏ธ", "โ™ฃ๏ธ") RANKS = ( @@ -114,22 +113,35 @@ class jackChips: self.total -= self.bet self.winnings -= 1 -def success_rate(card, obj_h): - """ Calculate Success rate of 'HIT' new cards """ - msg = "" - rate = 0 - diff = 21 - obj_h.value - if diff != 0: - rate = (VALUES[card[0][1]] / diff) * 100 +def success_rate(next_card, player_hand): + # Estimate the chance of a successful 'HIT' (not busting) in blackjack. - if rate < 100: - msg += f"If Hit, chance {int(rate)}% failure, {100-int(rate)}% success." - else: - l_rate = int(rate - (rate - 99)) # Round to 99 - if card[0][1] == "A": - l_rate -= 99 - msg += f"If Hit, chance {100-l_rate}% failure, and {l_rate}% success" - return msg + # If player already has 21 or more, hitting will always bust + if player_hand.value >= 21: + return "\n๐Ÿง  What do you think?" + + # Calculate how much more the player can add without busting + max_safe = 21 - player_hand.value + + safe_cards = 0 + total_cards = 0 + for rank in VALUES: + # 4 cards of each rank in a standard deck + count = 4 + card_value = VALUES[rank] + # Ace can be 1 or 11, but here we treat it as 1 if 11 would bust + if rank == "A": + card_value = 1 if player_hand.value + 11 > 21 else 11 + # Count as safe if it won't bust the player + if card_value <= max_safe: + safe_cards += count + total_cards += count + + # Calculate probability + success_chance = int((safe_cards / total_cards) * 100) + fail_chance = 100 - success_chance + + return f"\n๐Ÿง Hit: {fail_chance}% ๐Ÿ‘Ž, {success_chance}% ๐Ÿ‘" def hits(obj_de): new_card = [obj_de.deal_cards()[0][0]] @@ -147,12 +159,12 @@ def display_hand(hand): def show_some(player_cards, dealer_cards, obj_h): msg = f"Player[{obj_h.value}] {display_hand(player_cards)} " - msg += f"Dealer[{VALUES[dealer_cards[1][1]]}] {dealer_cards[1][1]}{dealer_cards[1][0]} " + msg += f"\nDealer[{VALUES[dealer_cards[1][1]]}] {dealer_cards[1][1]}{dealer_cards[1][0]} " return msg def show_all(player_cards, dealer_cards, obj_h, obj_d): msg = f"Player[{obj_h.value}] {display_hand(player_cards)} " - msg += f"Dealer[{obj_d.value}] {display_hand(dealer_cards)}" + msg += f"\nDealer[{obj_d.value}] {display_hand(dealer_cards)}" return msg def player_bust(obj_h, obj_c): @@ -229,7 +241,7 @@ def loadHSJack(): pickle.dump(highScore, file) return 0 -def playBlackJack(nodeID, message): +def playBlackJack(nodeID, message, last_cmd=None): # Initalize the Game msg, last_cmd = '', None blackJack = False @@ -267,10 +279,12 @@ def playBlackJack(nodeID, message): if last_cmd is None: # create new player if not in tracker - logger.debug(f"System: BlackJack: New Player {nodeID}") - jackTracker.append({'nodeID': nodeID, 'cmd': 'new', 'time': time.time(), 'cash': jack_starting_cash,\ - 'bet': 0, 'gameStats': {'p_win': p_win, 'd_win': d_win, 'draw': draw}, 'p_cards':p_cards, 'd_cards':d_cards, 'p_hand':p_hand.cards, 'd_hand':d_hand.cards, 'next_card':next_card}) - return f"Welcome to โ™ ๏ธโ™ฅ๏ธBlackJackโ™ฃ๏ธโ™ฆ๏ธ you have {p_chips.total} chips. Whats your bet?" + if nodeID != 0: + #logger.debug(f"System: BlackJack: New Player {nodeID}") + jackTracker.append({'nodeID': nodeID, 'cmd': 'new', 'last_played': time.time(), 'cash': jack_starting_cash,\ + 'bet': 0, 'gameStats': {'p_win': p_win, 'd_win': d_win, 'draw': draw}, 'p_cards':p_cards, 'd_cards':d_cards, 'p_hand':p_hand.cards, 'd_hand':d_hand.cards, 'next_card':next_card}) + return f"You have {p_chips.total} chips. Whats your bet?" + return "Error: Player not found." if getLastCmdJack(nodeID) == "new": # Place Bet @@ -283,24 +297,26 @@ def playBlackJack(nodeID, message): #resend the hand msg += show_some(p_cards, d_cards, p_hand) return msg + elif "blackjack" in message.lower(): + return f"\nTo place a bet, enter the amount you wish to wager." else: try: bet_money = int(message) except ValueError: - return "Invalid Bet, please enter a valid number." + return f"\nInvalid Bet, please enter a valid number." if bet_money <= p_chips.total and bet_money >= 1: p_chips.bet = bet_money else: - return f"Invalid Bet, the maximum bet you can place is {p_chips.total} and the minimum bet is 1." + return f"\nInvalid Bet, the maximum bet you can place is {p_chips.total} and the minimum bet is 1." except ValueError: - return f"Invalid Bet, the maximum bet, {p_chips.total}" + return f"\nInvalid Bet, the maximum bet, {p_chips.total}" # Show the cards msg += show_some(p_cards, d_cards, p_hand) # check for blackjack 21 and only two cards if p_hand.value == 21 and len(p_hand.cards) == 2: - msg += "Player ๐ŸŽฐ BLAAAACKJACKKKK ๐Ÿ’ฐ" + msg += f"\n๐ŸŽฐ BLAAAACKJACKKKK ๐Ÿ’ฐ" p_chips.total += round(p_chips.bet * 1.5) setLastCmdJack(nodeID, "dealerTurn") blackJack = True @@ -317,7 +333,7 @@ def playBlackJack(nodeID, message): if getLastCmdJack(nodeID) == "betPlaced": setLastCmdJack(nodeID, "playing") - msg += "(H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table" + msg += f"\n(H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table" # save the game state for i in range(len(jackTracker)): @@ -367,7 +383,7 @@ def playBlackJack(nodeID, message): # Check if player bust if player_bust(p_hand, p_chips): d_win += 1 - msg += "๐Ÿ’ฅPlayerBUST๐Ÿ’ฅ" + msg += f"\n๐Ÿ’ฅPlayerBUST๐Ÿ’ฅ" setLastCmdJack(nodeID, "dealerTurn") if getLastCmdJack(nodeID) == "playing": @@ -419,7 +435,7 @@ def playBlackJack(nodeID, message): d_hand.add_cards(d_card) if dealer_bust(d_hand, p_hand, p_chips): p_win += 1 - msg += "๐Ÿ’ฐDealerBUST๐Ÿ’ฅ" + msg += f"\n๐Ÿ’ฐDealerBUST๐Ÿ’ฅ" break # Show all cards msg += show_all(p_hand.cards, d_hand.cards, p_hand, d_hand) @@ -427,15 +443,15 @@ def playBlackJack(nodeID, message): # Check who wins if push(p_hand, d_hand): draw += 1 - msg += "๐Ÿ‘ŒPUSH" + msg += f"\n๐Ÿ‘ŒPUSH" elif player_wins(p_hand, d_hand, p_chips): p_win += 1 - msg += "๐ŸŽ‰PLAYER WINS๐ŸŽฐ" + msg += f"\n๐ŸŽ‰PLAYER WINS๐ŸŽฐ" elif dealer_wins(p_hand, d_hand, p_chips): d_win += 1 - msg += "๐Ÿ‘ŽDEALER WINS" + msg += f"\n๐Ÿ‘ŽDEALER WINS" else: - msg += "๐Ÿ‘ŽDEALER WINS" + msg += f"\n๐Ÿ‘ŽDEALER WINS" # Display the Game Stats msg += gameStats(str(p_win), str(d_win), str(draw)) @@ -443,20 +459,20 @@ def playBlackJack(nodeID, message): # Display the chips left if p_chips.total < 1: if p_chips.total > 0: - msg += "๐Ÿช™Keep the change you filthy animal!" + msg += f"\n๐Ÿช™Keep the change you filthy animal!" else: - msg += "๐Ÿ’ธNO MORE CHIPS!๐Ÿง๐Ÿ’ณ" + msg += f"\n๐Ÿ’ธNO MORE CHIPS!๐Ÿง๐Ÿ’ณ" p_chips.total = jack_starting_cash else: # check high score highScore = loadHSJack() if highScore != 0 and p_chips.total > highScore['highScore']: - msg += f"๐Ÿ’ฐHighScore๐Ÿ’ฐ{p_chips.total} " + msg += f"\n๐Ÿ’ฐHighScore๐Ÿ’ฐ{p_chips.total} " saveHSJack(nodeID, p_chips.total) else: - msg += f"๐Ÿ’ฐYou have {p_chips.total} chips " + msg += f"\n๐Ÿ’ฐYou have {p_chips.total} chips " - msg += " Bet or Leave?" + msg += f"\nBet or Leave?" # Reset the game setLastCmdJack(nodeID, "new") @@ -468,6 +484,6 @@ def playBlackJack(nodeID, message): jackTracker[i]['d_cards'] = [] jackTracker[i]['p_hand'] = [] jackTracker[i]['d_hand'] = [] - jackTracker[i]['time'] = time.time() + jackTracker[i]['last_played'] = time.time() return msg diff --git a/modules/games/dopewar.py b/modules/games/dopewar.py index eb578ce..8537275 100644 --- a/modules/games/dopewar.py +++ b/modules/games/dopewar.py @@ -14,7 +14,7 @@ dwInventoryDb = [{'userID': 1234567890, 'inventory': 0, 'priceList': [], 'amount dwCashDb = [{'userID': 1234567890, 'cash': starting_cash},] dwGameDayDb = [{'userID': 1234567890, 'day': 0},] dwLocationDb = [{'userID': 1234567890, 'location': 'USA', 'loc_choice': 0},] -dwPlayerTracker = [{'userID': 1234567890, 'last_played': time.time(), 'cmd': 'start'},] +from modules.settings import dwPlayerTracker # high score is saved in a pickle file dwHighScore = {} @@ -366,7 +366,8 @@ def get_location_table(nodeID, choice=0): return loc_table_string def endGameDw(nodeID): - global dwCashDb, dwInventoryDb, dwLocationDb, dwGameDayDb, dwHighScore + global dwCashDb, dwInventoryDb, dwLocationDb, dwGameDayDb, dwHighScore, dwPlayerTracker + cash = 0 msg = '' dwHighScore = getHighScoreDw() # Confirm the cash for the user @@ -375,23 +376,6 @@ def endGameDw(nodeID): cash = dwCashDb[i].get('cash') logger.debug("System: DopeWars: Game Over for user: " + str(nodeID) + " with cash: " + str(cash)) - # remove the player from the game databases - for i in range(0, len(dwCashDb)): - if dwCashDb[i].get('userID') == nodeID: - dwCashDb.pop(i) - for i in range(0, len(dwInventoryDb)): - if dwInventoryDb[i].get('userID') == nodeID: - dwInventoryDb.pop(i) - for i in range(0, len(dwLocationDb)): - if dwLocationDb[i].get('userID') == nodeID: - dwLocationDb.pop(i) - for i in range(0, len(dwGameDayDb)): - if dwGameDayDb[i].get('userID') == nodeID: - dwGameDayDb.pop(i) - for i in range(0, len(dwPlayerTracker)): - if dwPlayerTracker[i].get('userID') == nodeID: - dwPlayerTracker.pop(i) - # checks if the player's score is higher than the high score and writes a new high score if it is if cash > dwHighScore.get('cash'): dwHighScore = ({'userID': nodeID, 'cash': round(cash, 2)}) @@ -680,6 +664,7 @@ def playDopeWars(nodeID, cmd): for i in range(0, len(dwPlayerTracker)): if dwPlayerTracker[i].get('userID') == nodeID: dwPlayerTracker[i]['cmd'] = 'ask_bsf' + dwPlayerTracker[i]['last_played'] = time.time() # Game end if game_day == total_days + 1: diff --git a/modules/games/golfsim.py b/modules/games/golfsim.py index 33a08ca..97e0250 100644 --- a/modules/games/golfsim.py +++ b/modules/games/golfsim.py @@ -26,7 +26,7 @@ par4_5_range = par4_range + par5_range # Player setup playingHole = False -golfTracker = [{'nodeID': 0, 'last_played': time.time(), 'cmd': '', 'hole': 0, 'distance_remaining': 0, 'hole_shots': 0, 'hole_strokes': 0, 'hole_to_par': 0, 'total_strokes': 0, 'total_to_par': 0, 'par': 0, 'hazard': ''}] +from modules.settings import golfTracker # Club functions def hit_driver(): @@ -122,9 +122,8 @@ def getHighScoreGolf(nodeID, strokes, par): return 0 # Main game loop -def playGolf(nodeID, message, finishedHole=False): +def playGolf(nodeID, message, finishedHole=False, last_cmd=''): msg = '' - global golfTracker # Course setup par3_count = 0 par4_count = 0 @@ -133,6 +132,7 @@ def playGolf(nodeID, message, finishedHole=False): total_strokes = 0 total_to_par = 0 par = 0 + hole = 1 # get player's last command from tracker if not new player last_cmd = "" @@ -145,8 +145,12 @@ def playGolf(nodeID, message, finishedHole=False): par = golfTracker[i]['par'] total_strokes = golfTracker[i]['total_strokes'] total_to_par = golfTracker[i]['total_to_par'] - - if last_cmd == "" or last_cmd == "new": + #update last played time + for i in range(len(golfTracker)): + if golfTracker[i]['nodeID'] == nodeID: + golfTracker[i]['last_played'] = time.time() + + if last_cmd == "new": # Start a new hole if hole <= 9: # Set up hole count restrictions on par @@ -193,17 +197,19 @@ def playGolf(nodeID, message, finishedHole=False): # Set initial parameters before starting a hole distance_remaining = hole_length hole_shots = 0 + last_cmd = 'stroking' # save player's current game state for i in range(len(golfTracker)): if golfTracker[i]['nodeID'] == nodeID: + golfTracker[i]['cmd'] = last_cmd + golfTracker[i]['hole'] = hole golfTracker[i]['distance_remaining'] = distance_remaining golfTracker[i]['cmd'] = 'stroking' golfTracker[i]['par'] = par golfTracker[i]['total_strokes'] = total_strokes golfTracker[i]['total_to_par'] = total_to_par golfTracker[i]['hazard'] = hazard - golfTracker[i]['hole'] = hole golfTracker[i]['last_played'] = time.time() golfTracker[i]['hole_shots'] = hole_shots @@ -320,8 +326,8 @@ def playGolf(nodeID, message, finishedHole=False): else: last_cmd = 'stroking' else: - msg += "\nYou have " + str(distance_remaining) + "yd. โ›ณ๏ธ" - msg += "\nClub?[D, L, M, H, G, W]๐ŸŒ๏ธ" + msg += f"\nYou have " + str(distance_remaining) + "yd. โ›ณ๏ธ" + msg += f"\nClub?[D, L, M, H, G, W]๐ŸŒ๏ธ" # save player's current game state, keep stroking @@ -365,7 +371,7 @@ def playGolf(nodeID, message, finishedHole=False): if hole not in [1, 10]: # Show player total scoring info for the round, except hole 1 and 10 - msg += "\nYou've hit a total of " + str(total_strokes) + " strokes today, for" + msg += f"\nYou've hit a total of " + str(total_strokes) + " strokes today, for" msg += getScorecardGolf(total_to_par) # Move to next hole @@ -403,7 +409,7 @@ def playGolf(nodeID, message, finishedHole=False): logger.debug("System: GolfSim: Player " + str(nodeID) + " has finished their round.") else: # Show player the next hole - msg += playGolf(nodeID, 'new', True) - msg += "\n๐ŸŒ๏ธ[D, L, M, H, G, W, End]๐ŸŒ๏ธ" + msg += playGolf(nodeID, '', True, last_cmd='new') + msg += f"\n๐ŸŒ๏ธ[D, L, M, H, G, W, End]๐ŸŒ๏ธ" return msg diff --git a/modules/games/joke.py b/modules/games/joke.py index c838779..71949b5 100644 --- a/modules/games/joke.py +++ b/modules/games/joke.py @@ -2,8 +2,73 @@ # The emoji table of contents is used to replace words in the joke with emojis # As a Ham, is this obsecuring the meaning of the joke? Or is it enhancing it? from dadjokes import Dadjoke # pip install dadjokes +import random from modules.log import * +lameJokes = [ + "Why don't scientists trust atoms? Because they make up everything!", + "Why did the scarecrow win an award? Because he was outstanding in his field!", + "Why don't skeletons fight each other? They don't have the guts.", + "What do you call fake spaghetti? An impasta!", + "Why did the bicycle fall over? Because it was two-tired!", + "Why did the math book look sad? Because it had too many problems.", + "Why did the golfer bring two pairs of pants? In case he got a hole in one.", + "Why did the coffee file a police report? It got mugged.", + "Why did the tomato turn red? Because it saw the salad dressing!", + "Why did the cookie go to the doctor? Because it felt crummy.", + "Why did the computer go to the doctor? Because it had a virus!", + "Why did the chicken join a band? Because it had the drumsticks!", + "Why did the banana go to the doctor? Because it wasn't peeling well.", + "Why did the cow go to space? To see the moooon!", + "Why did the fish blush? Because it saw the ocean's bottom!", + "Why did the elephant bring a suitcase to the zoo? Because it wanted to pack its trunk!", + "Why did the meshtastic node go to therapy? It had too many connections to handle!", + "Why did the meshtastic user bring a ladder to the meeting? To reach new heights in communication!", + "Why did the meshtastic device break up with Wi-Fi? It found a better connection!", + "Why did the meshtastic network throw a party? Because it wanted to mesh well with everyone!", + "Why did the meshtastic node get promoted? Because it was outstanding in its field!", + "Why did the meshtastic user bring a map? To navigate the mesh of possibilities!", + "Why did the meshtastic device go to school? To improve its signal strength!", + "How did the meshtastic node become a comedian? It uses mesh-bots to deliver punchlines!", + "Chuck Norris doesn't read books. He stares them down until he gets the information he wants.", + "When Chuck Norris enters a room, he doesn't turn the lights on. He turns the dark off.", + "Chuck Norris can divide by zero.", + "Chuck Norris counted to infinity. Twice.", + "Chuck Norris can slam a revolving door.", + "When Chuck Norris does a push-up, he isn't lifting himself up; he's pushing the Earth down.", + "Chuck Norris can hear sign language.", + "Death once had a near-Chuck Norris experience.", + "Chuck Norris can unscramble an egg.", + "Chuck Norris can win a game of Connect Four in only three moves.", + "Chuck Norris can make a snowman out of rain.", + "Chuck Norris can strangle you with a cordless phone.", + "Chuck Norris can do a wheelie on a unicycle.", + "Chuck Norris can kill two stones with one bird.", + "Chuck Norris can speak braille.", + "Chuck Norris can build a snowman out of rain.", + "This is a test. A test of the Joke Brodcast System. If this had been an actual joke, you would have been amused.", + "Chuck Norris doesn't join mesh networks. Mesh networks join Chuck's topology.", + "Every time Chuck Norris sends a packet, it arrives before he hits 'send'", + "Chuck Norris doesn't need LoRa. His roundhouse kick has a 15km range with zero latency.", + "When Chuck Norris uses a node, the bandwidth doubles out of fear.", + "Chuck Norris once pinged a device. It replied with an apology and a firmware update.", + "Chuck Norris doesn't use AES encryption. His packets are so secure, they punch hackers in the bits.", + "The Meshtastic protocol has a hidden mode: โ€œChuck Norris mode.โ€ It only activates when he blinks.", + "Chuck Norris doesn't need a GPS fix. Satellites triangulate themselves around him.", + "Chuck Norris's mesh node doesn't sleep. It meditates while transmitting at full power.", + "Chuck Norris doesn't broadcast. He declares.", + "Chuck Norris once bridged two mesh networks using a shoelace.", + "Chuck Norris's packets don't hop. They teleport out of respect.", + "Chuck Norris doesn't need a repeater. Client_Mute is set to 'Always'.", + "Chuck Norris's mesh messages are entangled. When he sends one, it's already received.", + "Chuck Norris doesn't mesh with others. Others mesh with Chuck.", + "Chuck Norris's node doesn't need a case. The PCB is armored with his beard hair.", + "Chuck Norris once typed โ€œHello Worldโ€ and the world replied 'Hello Chuck.'", +] + +# pylint: disable=C0103, W0612 +imtellingyourightnowiAmTellingYouRightNowThatMotherfErBackThereIsNotReal = ["๐Ÿฆ", "๐Ÿฆ…", "๐Ÿฆ†", "๐Ÿฆ‰", "๐Ÿฆœ", "๐Ÿค", "๐Ÿฅ", "๐Ÿฃ", "๐Ÿ”", "๐Ÿง", "๐Ÿฆš", "๐Ÿฆข", "๐Ÿฆฉ", "๐Ÿฆค", "๐Ÿฆƒ", "๐Ÿ“"] + def tableOfContents(): wordToEmojiMap = { 'love': 'โค๏ธ', 'heart': 'โค๏ธ', 'happy': '๐Ÿ˜Š', 'smile': '๐Ÿ˜Š', 'sad': '๐Ÿ˜ข', 'angry': '๐Ÿ˜ ', 'mad': '๐Ÿ˜ ', 'cry': '๐Ÿ˜ข', 'laugh': '๐Ÿ˜‚', 'funny': '๐Ÿ˜‚', 'cool': '๐Ÿ˜Ž', @@ -115,12 +180,14 @@ def sendWithEmoji(message): i += 1 return ' '.join(words) -def tell_joke(nodeID=0): +def tell_joke(nodeID=0, vox=False): dadjoke = Dadjoke() - - if dad_jokes_emojiJokes: - renderedLaugh = sendWithEmoji(dadjoke.joke) - else: - renderedLaugh = dadjoke.joke - return renderedLaugh + try: + if dad_jokes_emojiJokes or vox: + renderedLaugh = sendWithEmoji(dadjoke.joke) + else: + renderedLaugh = dadjoke.joke + return renderedLaugh + except Exception as e: + return random.choice(lameJokes) diff --git a/modules/games/lemonade.py b/modules/games/lemonade.py index f372aaf..73da20a 100644 --- a/modules/games/lemonade.py +++ b/modules/games/lemonade.py @@ -18,12 +18,12 @@ locale.setlocale(locale.LC_ALL, '') lemon_starting_cash = 30.00 lemon_total_weeks = 7 -lemonadeTracker = [{'nodeID': 0, 'cups': 0, 'lemons': 0, 'sugar': 0, 'cash': lemon_starting_cash, 'start': lemon_starting_cash, 'cmd': 'new', 'time': time.time()}] lemonadeCups = [{'nodeID': 0, 'cost': 2.50, 'count': 25, 'min': 0.99, 'unit': 0.00}] lemonadeLemons = [{'nodeID': 0, 'cost': 4.00, 'count': 8, 'min': 2.00, 'unit': 0.00}] lemonadeSugar = [{'nodeID': 0, 'cost': 3.00, 'count': 15, 'min': 1.50, 'unit': 0.00}] lemonadeWeeks = [{'nodeID': 0, 'current': 1, 'total': lemon_total_weeks, 'sales': 99, 'potential': 0, 'unit': 0.00, 'price': 0.00, 'total_sales': 0}] lemonadeScore = [{'nodeID': 0, 'value': 0.00, 'total': 0.00}] +from modules.settings import lemonadeTracker def get_sales_amount(potential, unit, price): """Gets the sales amount. @@ -50,12 +50,14 @@ def getHighScoreLemon(): pickle.dump(high_score, file) return high_score -def start_lemonade(nodeID, message, celsius=False): +def playLemonstand(nodeID, message, celsius=False, newgame=False): global lemonadeTracker, lemonadeCups, lemonadeLemons, lemonadeSugar, lemonadeWeeks, lemonadeScore + msg = "" potential = 0 unit = 0.0 price = 0.0 total_sales = 0 + lemonsLastCmd = '' high_score = getHighScoreLemon() @@ -94,33 +96,6 @@ def start_lemonade(nodeID, message, celsius=False): lemonadeScore[i]['value'] = score.value lemonadeScore[i]['total'] = score.total - def endGame(nodeID): - # remove the player from the tracker - for i in range(len(lemonadeTracker)): - if lemonadeTracker[i]['nodeID'] == nodeID: - lemonadeTracker.pop(i) - for i in range(len(lemonadeCups)): - if lemonadeCups[i]['nodeID'] == nodeID: - lemonadeCups.pop(i) - for i in range(len(lemonadeLemons)): - if lemonadeLemons[i]['nodeID'] == nodeID: - lemonadeLemons.pop(i) - for i in range(len(lemonadeSugar)): - if lemonadeSugar[i]['nodeID'] == nodeID: - lemonadeSugar.pop(i) - for i in range(len(lemonadeWeeks)): - if lemonadeWeeks[i]['nodeID'] == nodeID: - lemonadeWeeks.pop(i) - for i in range(len(lemonadeScore)): - if lemonadeScore[i]['nodeID'] == nodeID: - lemonadeScore.pop(i) - logger.debug("System: Lemonade: Game Over for " + str(nodeID)) - - # Check for end of game - if message.lower().startswith("e"): - endGame(nodeID) - return "Goodbye!๐Ÿ‘‹" - title="LemonStand๐Ÿ‹" # Define the temperature unit symbols fahrenheit_unit = "ยบF" @@ -213,7 +188,7 @@ def start_lemonade(nodeID, message, celsius=False): inventory.sugar = lemonadeTracker[i]['sugar'] inventory.cash = lemonadeTracker[i]['cash'] inventory.start = lemonadeTracker[i]['start'] - last_cmd = lemonadeTracker[i]['cmd'] + lemonsLastCmd = lemonadeTracker[i]['cmd'] for i in range(len(lemonadeCups)): if lemonadeCups[i]['nodeID'] == nodeID: cups.cost = lemonadeCups[i]['cost'] @@ -239,15 +214,35 @@ def start_lemonade(nodeID, message, celsius=False): if lemonadeScore[i]['nodeID'] == nodeID: score.value = lemonadeScore[i]['value'] score.total = lemonadeScore[i]['total'] - + if (newgame): + # reset the game values + inventory.cups = 0 + inventory.lemons = 0 + inventory.sugar = 0 + inventory.cash = lemon_starting_cash + inventory.start = lemon_starting_cash + cups.cost = 2.50 + cups.unit = round(cups.cost / cups.count, 2) + lemons.cost = 4.00 + lemons.unit = round(lemons.cost / lemons.count, 2) + sugar.cost = 3.00 + sugar.unit = round(sugar.cost / sugar.count, 2) + weeks.current = 1 + weeks.total_sales = 0 + weeks.summary = [] + score.value = 0.00 + score.total = 0.00 + lemonsLastCmd = "cups" + # set the last command to new in the inventory db + for i in range(len(lemonadeTracker)): + if lemonadeTracker[i]['nodeID'] == nodeID: + lemonadeTracker[i]['cmd'] = "cups" + lemonadeTracker[i]['last_played'] = time.time() + saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) # Start the main loop if (weeks.current <= weeks.total): - - if "new" in last_cmd: - # set the last command to cups in the inventory db - for i in range(len(lemonadeTracker)): - if lemonadeTracker[i]['nodeID'] == nodeID: - lemonadeTracker[i]['cmd'] = "cups" + if newgame or "new" in lemonsLastCmd: + logger.debug("System: Lemonade: New Game: " + str(nodeID)) # Create a new display buffer for the text messages buffer= "" @@ -264,7 +259,7 @@ def start_lemonade(nodeID, message, celsius=False): buffer += ". " + \ formatted + temperature.units + " " + \ forecastd[list(forecastd)[temperature.forecast]][2] + \ - " " + glyph + " " + glyph + f"\n" # Calculate the potential sales as a percentage of the maximum value # (lower temperature = fewer sales, severe weather = fewer sales) @@ -292,44 +287,39 @@ def start_lemonade(nodeID, message, celsius=False): sugar.unit = round(sugar.cost / sugar.count, 2) # Calculate the unit cost and display the estimated sales from the forecast potential - unit = cups.unit + lemons.unit + sugar.unit - buffer += " SupplyCost" + locale.currency(unit, grouping=True) + " a cup." - buffer += " Sales Potential:" + str(potential) + " cups." + unit = max(0.01, min(cups.unit + lemons.unit + sugar.unit, 4.0)) # limit the unit cost between $0.01 and $4.00 + buffer += f"\nSupplyCost" + locale.currency(round(unit, 2), grouping=True) + " a cup." + buffer += f"\nSales Potential:" + str(potential) + " cups." # Display the current inventory - buffer += " Inventory:" + buffer += f"\nInventory:" buffer += "๐Ÿฅค:" + str(inventory.cups) buffer += "๐Ÿ‹:" + str(inventory.lemons) buffer += "๐Ÿš:" + str(inventory.sugar) # Display the updated item prices - buffer += f"\nPrices: " - buffer += "๐Ÿฅค:" + \ - locale.currency(cups.cost, grouping=True) + " ๐Ÿ“ฆ of " + str(cups.count) + "." - buffer += " ๐Ÿ‹:" + \ - locale.currency(lemons.cost, grouping=True) + " ๐Ÿงบ of " + str(lemons.count) + "." - buffer += " ๐Ÿš:" + \ - locale.currency(sugar.cost, grouping=True) + " bag for " + str(sugar.count) + "๐Ÿฅค." - + buffer += f"\nPrices:\n" + buffer += f"\n๐Ÿฅค:" + locale.currency(round(cups.cost, 2), grouping=True) + " ๐Ÿ“ฆ of " + str(cups.count) + "." + buffer += f"\n๐Ÿ‹:" + locale.currency(round(lemons.cost, 2), grouping=True) + " ๐Ÿงบ of " + str(lemons.count) + "." + buffer += f"\n๐Ÿš:" + locale.currency(round(sugar.cost, 2), grouping=True) + " bag for " + str(sugar.count) + "๐Ÿฅค." # Display the current cash gainloss = inventory.cash - inventory.start - buffer += " ๐Ÿ’ต:" + \ - locale.currency(inventory.cash, grouping=True) + buffer += f"\n๐Ÿ’ต:" + locale.currency(round(inventory.cash, 2), grouping=True) # if the player is in the red - pnl = locale.currency(gainloss, grouping=True) + pnl = locale.currency(round(gainloss, 2), grouping=True) if "0.00" not in pnl: if pnl.startswith("-"): buffer += "๐Ÿ“ŠP&L๐Ÿ“‰" + pnl else: buffer += "๐Ÿ“ŠP&L๐Ÿ“ˆ" + pnl - buffer += f"\n๐Ÿฅค to buy? Have {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a ๐Ÿ“ฆ of {str(cups.count)}" + buffer += f"\n๐Ÿฅค to buy?\nHave {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a ๐Ÿ“ฆ of {str(cups.count)}" saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) return buffer - if "cups" in last_cmd: + if "cups" in lemonsLastCmd and not newgame: # Read the number of cup boxes to purchase newcups = -1 if "n" in message.lower(): @@ -343,22 +333,22 @@ def start_lemonade(nodeID, message, celsius=False): inventory.cups += (newcups * cups.count) inventory.cash -= cost msg = "Purchased " + str(newcups) + " ๐Ÿ“ฆ " - msg += str(inventory.cups) + " ๐Ÿฅค in inventory. " + locale.currency(inventory.cash, grouping=True) + f" remaining" + msg += str(inventory.cups) + " ๐Ÿฅค in inventory. " + locale.currency(round(inventory.cash, 2), grouping=True) + f" remaining" else: msg = "No ๐Ÿฅค were purchased" except Exception as e: return "invalid input, enter the number of ๐Ÿฅค to purchase or (N)one" + msg += f"\n ๐Ÿ‹ to buy?\nHave {inventory.lemons}๐Ÿฅค of ๐Ÿ‹ Cost {locale.currency(lemons.cost, grouping=True)} a ๐Ÿงบ for {str(lemons.count)}๐Ÿฅค" # set the last command to lemons in the inventory db for i in range(len(lemonadeTracker)): if lemonadeTracker[i]['nodeID'] == nodeID: lemonadeTracker[i]['cmd'] = "lemons" saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) - msg += f"\n ๐Ÿ‹ to buy? Have {inventory.lemons}๐Ÿฅค of ๐Ÿ‹ Cost {locale.currency(lemons.cost, grouping=True)} a ๐Ÿงบ for {str(lemons.count)}๐Ÿฅค" return msg - if "lemons" in last_cmd: + if "lemons" in lemonsLastCmd and not newgame: # Read the number of lemon bags to purchase newlemons = -1 if "n" in message.lower(): @@ -379,15 +369,15 @@ def start_lemonade(nodeID, message, celsius=False): newlemons = -1 return "โ›”๏ธinvalid input, enter the number of ๐Ÿ‹ to purchase" + msg += f"\n ๐Ÿš to buy?\nYou have {inventory.sugar}๐Ÿฅค of ๐Ÿš, Cost {locale.currency(sugar.cost, grouping=True)} a bag for {str(sugar.count)}๐Ÿฅค" # set the last command to sugar in the inventory db for i in range(len(lemonadeTracker)): if lemonadeTracker[i]['nodeID'] == nodeID: lemonadeTracker[i]['cmd'] = "sugar" saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) - msg += f"\n ๐Ÿš to buy? You have {inventory.sugar}๐Ÿฅค of ๐Ÿš, Cost {locale.currency(sugar.cost, grouping=True)} a bag for {str(sugar.count)}๐Ÿฅค" return msg - if "sugar" in last_cmd: + if "sugar" in lemonsLastCmd and not newgame: # Read the number of sugar bags to purchase newsugar = -1 if "n" in message.lower(): @@ -407,8 +397,8 @@ def start_lemonade(nodeID, message, celsius=False): except Exception as e: return "โ›”๏ธinvalid input, enter the number of ๐Ÿš bags to purchase" - msg += f"Cost of goods is {locale.currency(unit, grouping=True)}" - msg += f"per ๐Ÿฅค {locale.currency(inventory.cash, grouping=True)} ๐Ÿ’ต remaining." + msg += f"Cost of goods is {locale.currency(round(unit, 2), grouping=True)}" + msg += f"per ๐Ÿฅค {locale.currency(round(inventory.cash, 2), grouping=True)} ๐Ÿ’ต remaining." msg += f"\nPrice to Sell? or (G)rocery to buy more ๐Ÿฅค๐Ÿ‹๐Ÿš" # set the last command to price in the inventory db @@ -418,17 +408,17 @@ def start_lemonade(nodeID, message, celsius=False): saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) return msg - if "price" in last_cmd: + if "price" in lemonsLastCmd and not newgame: # set the last command to sales in the inventory db for i in range(len(lemonadeTracker)): if lemonadeTracker[i]['nodeID'] == nodeID: lemonadeTracker[i]['cmd'] = "sales" if "g" in message.lower(): lemonadeTracker[i]['cmd'] = "cups" - msg = f"#of๐Ÿฅค to buy? Have {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a ๐Ÿ“ฆ of {str(cups.count)}" + msg = f"#of๐Ÿฅค\nto buy? Have {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a ๐Ÿ“ฆ of {str(cups.count)}" return msg else: - last_cmd = "sales" + lemonsLastCmd = "sales" # Read the actual price price = 0.00 @@ -440,7 +430,7 @@ def start_lemonade(nodeID, message, celsius=False): return "The price must be greater than zero." except Exception as e: price = 0.00 - last_cmd = "price" + lemonsLastCmd = "price" return "โ›”๏ธInvalid input, enter the price of the lemonade per ๐Ÿฅค" # this isnt sent to the user, not needed @@ -448,7 +438,7 @@ def start_lemonade(nodeID, message, celsius=False): saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) - if "sales" in last_cmd: + if "sales" in lemonsLastCmd and not newgame: # Calculate the weekly sales based on price and lowest inventory level # (higher markup price = fewer sales, limited by the inventory on-hand) sales = get_sales_amount(potential, unit, price) @@ -478,7 +468,7 @@ def start_lemonade(nodeID, message, celsius=False): msg += " N.Profit:" + locale.currency(net, grouping=True) # Display the updated inventory levels - msg += "\nRemaining" + msg += f"\nRemaining" msg += " ๐Ÿฅค:" + str(inventory.cups) msg += " ๐Ÿ‹:" + str(inventory.lemons) msg += " ๐Ÿš:" + str(inventory.sugar) @@ -495,7 +485,7 @@ def start_lemonade(nodeID, message, celsius=False): pad_week = len(str(weeks.total)) pad_sale = len(str(weeks.sales)) total = 0 - msg += "\nWeekly๐Ÿ“Š" + msg += f"\nWeekly๐Ÿ“Š" for i in range(len(weeks.summary)): msg += "#" + str(weeks.current).rjust(pad_week) + ". " + str(weeks.summary[i]['sales']).rjust(pad_sale) + \ " sold x " + locale.currency(weeks.summary[i]['price'], grouping=True) + "ea. " @@ -535,7 +525,7 @@ def start_lemonade(nodeID, message, celsius=False): if (inventory.sugar <= 0): msg += " You ran out of sugar.๐Ÿš" else: - msg += "\nCongratulations ๐Ÿ‹๐Ÿ‹ your sales were perfect!๐ŸŽ‰" + msg += f"\nCongratulations ๐Ÿ‹๐Ÿ‹ your sales were perfect!๐ŸŽ‰" # Increment the score counters score.value = score.value + minnet @@ -546,32 +536,32 @@ def start_lemonade(nodeID, message, celsius=False): if (weeks.current == weeks.total): # end of the game success = round((score.value / score.total) * 100) - msg += "\nYou've made " + locale.currency(score.value, grouping=True) + " out of a possible " + \ + msg += f"\nYou've made " + locale.currency(score.value, grouping=True) + " out of a possible " + \ locale.currency(score.total, grouping=True) + " for a score of " + str(success) + "% " - msg += "You've sold " + str(weeks.total_sales) + " total ๐Ÿฅค๐Ÿ‹" + msg += f"\nYou've sold " + str(weeks.total_sales) + " total ๐Ÿฅค๐Ÿ‹" # check for high score high_score = getHighScoreLemon() if (inventory.cash > int(high_score['cash'])): - msg += "\nCongratulations! You've set a new high score!๐ŸŽ‰๐Ÿ’ฐ๐Ÿ‹" + msg += f"\nCongratulations! You've set a new high score!๐ŸŽ‰๐Ÿ’ฐ๐Ÿ‹" high_score['cash'] = inventory.cash high_score['success'] = success high_score['userID'] = nodeID with open('data/lemonstand.pkl', 'wb') as file: pickle.dump(high_score, file) - endGame(nodeID) else: # keep playing + + weeks.current = weeks.current + 1 + + msg += f"\nPlay another week๐Ÿฅค? or (E)nd Game" # set the last command to new in the inventory db for i in range(len(lemonadeTracker)): if lemonadeTracker[i]['nodeID'] == nodeID: lemonadeTracker[i]['cmd'] = "new" - lemonadeTracker[i]['time'] = time.time() - - weeks.current = weeks.current + 1 - - msg += f"Play another week๐Ÿฅค? or (E)nd Game" - + lemonadeTracker[i]['last_played'] = time.time() saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score) return msg + else: + return "Game Over! Start a (N)ew Game or (E)xit" diff --git a/modules/games/mmind.py b/modules/games/mmind.py index 8baf805..6d1d35b 100644 --- a/modules/games/mmind.py +++ b/modules/games/mmind.py @@ -5,9 +5,7 @@ import random import time import pickle from modules.log import * - -mindTracker = [{'nodeID': 0, 'last_played': time.time(), 'cmd': '', 'secret_code': '', 'diff': 'n', 'turns': 1}] - +from modules.settings import mindTracker def chooseDifficultyMMind(message): usrInput = message.lower() msg = '' @@ -62,98 +60,63 @@ def makeCodeMMind(diff): return secret_code #get guess from user -def getGuessMMind(diff, guess): - msg = '' - if diff == "n": - valid_colorsMMind = "RYGB" - elif diff == "h": - valid_colorsMMind = "RYGBOP" - elif diff == "x": - valid_colorsMMind = "RYGBOPWK" - - user_guess = guess.upper() - valid_guess = True - if len(user_guess) != 4: - valid_guess = False - for i in range(len(user_guess)): - if user_guess[i] not in valid_colorsMMind: - valid_guess = False - if valid_guess == False: - user_guess = "XXXX" +def getGuessMMind(diff, guess, nodeID): + valid_colors = { + "n": "RYGB", + "h": "RYGBOP", + "x": "RYGBOPWK" + } + user_guess = guess.strip().upper() + if len(user_guess) != 4 or any(c not in valid_colors.get(diff, "RYGB") for c in user_guess): + return "XXXX" + + #increase the turn count and store in tracker + for i in range(len(mindTracker)): + if mindTracker[i]['nodeID'] == nodeID: + mindTracker[i]['turns'] += 1 + mindTracker[i]['last_played'] = time.time() + mindTracker[i]['diff'] = diff return user_guess def getHighScoreMMind(nodeID, turns, diff): - # check if player is in high score list and pick the lowest score - try: - with open('mmind_hs.pkl', 'rb') as f: - mindHighScore = pickle.load(f) - except: - logger.debug("System: MasterMind: High Score file not found.") - mindHighScore = [{'nodeID': nodeID, 'turns': turns, 'diff': diff}] - with open('mmind_hs.pkl', 'wb') as f: - pickle.dump(mindHighScore, f) + import os + hs_file = 'data/mmind_hs.pkl' + # Try to load existing high scores + if os.path.exists(hs_file): + try: + with open(hs_file, 'rb') as f: + mindHighScore = pickle.load(f) + except Exception as e: + logger.debug(f"System: MasterMind: Error loading high score file: {e}") + mindHighScore = [] + else: + mindHighScore = [] + # If nodeID==0, just return 0 if nodeID == 0: - # just return the high score + mindHighScore = [{'nodeID': 0, 'turns': 0, 'diff': 'n'}] return mindHighScore - # calculate lowest score - lowest_score = mindHighScore[0]['turns'] + # If no high score, add this one + if not mindHighScore: + mindHighScore = [{'nodeID': nodeID, 'turns': turns, 'diff': diff}] + with open(hs_file, 'wb') as f: + pickle.dump(mindHighScore, f) + return mindHighScore - if mindHighScore[0]['diff'] == "n" and diff == "n": - if lowest_score > turns: - # update the high score for normal if new score is lower - mindHighScore[0]['nodeID'] = nodeID - mindHighScore[0]['turns'] = turns - mindHighScore[0]['diff'] = diff - - # write new high score to file - with open('mmind_hs.pkl', 'wb') as f: + # If the diff matches, compare and update if better + if mindHighScore[0]['diff'] == diff: + if turns < mindHighScore[0]['turns']: + mindHighScore[0] = {'nodeID': nodeID, 'turns': turns, 'diff': diff} + with open(hs_file, 'wb') as f: pickle.dump(mindHighScore, f) - return mindHighScore - elif mindHighScore[0]['diff'] == "n" and diff == "h": - # update the high score for hard if normal is the only high score - mindHighScore[0]['nodeID'] = nodeID - mindHighScore[0]['turns'] = turns - mindHighScore[0]['diff'] = diff - - # write new high score to file - with open('mmind_hs.pkl', 'wb') as f: - pickle.dump(mindHighScore, f) return mindHighScore - elif mindHighScore[0]['diff'] == "h" and diff == "h": - if lowest_score > turns: - # update the high score for hard if new score is lower - mindHighScore[0]['nodeID'] = nodeID - mindHighScore[0]['turns'] = turns - mindHighScore[0]['diff'] = diff - - # write new high score to file - with open('mmind_hs.pkl', 'wb') as f: - pickle.dump(mindHighScore, f) - return mindHighScore - elif mindHighScore[0]['diff'] == "n" or mindHighScore[0]['diff'] == "h" and diff == "x": - # update the high score for expert if normal or high is the only high score - mindHighScore[0]['nodeID'] = nodeID - mindHighScore[0]['turns'] = turns - mindHighScore[0]['diff'] = diff - - # write new high score to file - with open('mmind_hs.pkl', 'wb') as f: - pickle.dump(mindHighScore, f) - return mindHighScore - elif mindHighScore[0]['diff'] == "x" and diff == "x": - if lowest_score > turns: - # update the high score for expert if new score is lower - mindHighScore[0]['nodeID'] = nodeID - mindHighScore[0]['turns'] = turns - mindHighScore[0]['diff'] = diff - - # write new high score to file - with open('mmind_hs.pkl', 'wb') as f: - pickle.dump(mindHighScore, f) - return mindHighScore - return 0 + + # If the diff is different, replace with new high score for new diff + mindHighScore[0] = {'nodeID': nodeID, 'turns': turns, 'diff': diff} + with open(hs_file, 'wb') as f: + pickle.dump(mindHighScore, f) + return mindHighScore def getEmojiMMind(secret_code): @@ -182,7 +145,7 @@ def getEmojiMMind(secret_code): return secret_code_emoji #compare userGuess with secret code and provide feedback -def compareCodeMMind(secret_code, user_guess): +def compareCodeMMind(secret_code, user_guess, nodeID): game_won = False perfect_pins = 0 wrong_position = 0 @@ -210,9 +173,26 @@ def compareCodeMMind(secret_code, user_guess): temp_code.remove(guess) # Remove the first occurrence of the matched color # display feedback if game_won: - msg += f"Correct{getEmojiMMind(user_guess)}\n" + msg += f"\n๐Ÿ†Correct{getEmojiMMind(user_guess)}\nYou are the master mind!๐Ÿคฏ" + # get turn count from tracker + for i in range(len(mindTracker)): + if mindTracker[i]['nodeID'] == nodeID: + turns = mindTracker[i]['turns'] - 2 # subtract 2 to account for increment after last guess and starting at 1 + diff = mindTracker[i]['diff'] + # get high score + high_score = getHighScoreMMind(nodeID, turns, diff) + if high_score[0]['turns'] != 0: + msg += f"\n๐Ÿ† High Score:{turns} turns, Difficulty:{diff}" + # reset turn count in tracker + msg += f"\nWould you like to play again? (N)ormal, (H)ard, or e(X)pert?" + # reset turn count in tracker + for i in range(len(mindTracker)): + if mindTracker[i]['nodeID'] == nodeID: + mindTracker[i]['turns'] = 0 + mindTracker[i]['secret_code'] = '' + mindTracker[i]['cmd'] = 'new' else: - msg += f"Guess{getEmojiMMind(user_guess)}\n" + msg += f"\nGuess{getEmojiMMind(user_guess)}\n" if perfect_pins > 0 and game_won == False: msg += "โœ… color โœ… position: {}".format(perfect_pins) @@ -231,11 +211,11 @@ def playGameMMind(diff, secret_code, turn_count, nodeID, message): msg = '' won = False if turn_count <= 10: - user_guess = getGuessMMind(diff, message) + user_guess = getGuessMMind(diff, message, nodeID) if user_guess == "XXXX": msg += f"โ›”๏ธInvalid guess. Please enter 4 valid colors letters.\n๐Ÿ”ด๐ŸŸข๐Ÿ”ต๐Ÿ”ด is RGBR" return msg - check_guess = compareCodeMMind(secret_code, user_guess) + check_guess = compareCodeMMind(secret_code, user_guess, nodeID) # display turn count and feedback msg += "Turn {}:".format(turn_count) @@ -245,18 +225,6 @@ def playGameMMind(diff, secret_code, turn_count, nodeID, message): if won == True: msg += f"\n๐ŸŽ‰๐Ÿง  you win ๐Ÿฅท๐Ÿคฏ" - # get high score - high_score = getHighScoreMMind(nodeID, turn_count, diff) - if high_score != 0: - msg += f"\n๐Ÿ† High Score:{high_score[0]['turns']} turns, Difficulty:{high_score[0]['diff'].upper()}" - - msg += "\nWould you like to play again?\n(N)ormal, (H)ard, e(X)pert (E)nd?" - # reset turn count in tracker - for i in range(len(mindTracker)): - if mindTracker[i]['nodeID'] == nodeID: - mindTracker[i]['turns'] = 1 - mindTracker[i]['secret_code'] = '' - mindTracker[i]['cmd'] = 'new' else: # increment turn count and keep playing turn_count += 1 @@ -266,12 +234,12 @@ def playGameMMind(diff, secret_code, turn_count, nodeID, message): mindTracker[i]['turns'] = turn_count elif won == False: msg += f"๐Ÿ™‰Game Over๐Ÿ™ˆ\nThe code was: {getEmojiMMind(secret_code)}" - msg += "\nYou have run out of turns.๐Ÿ˜ฟ" - msg += "\nWould you like to play again? (N)ormal, (H)ard, or e(X)pert?" + msg += f"\nYou have run out of turns.๐Ÿ˜ฟ" + msg += f"\nWould you like to play again? (N)ormal, (H)ard, or e(X)pert?" # reset turn count in tracker for i in range(len(mindTracker)): if mindTracker[i]['nodeID'] == nodeID: - mindTracker[i]['turns'] = 1 + mindTracker[i]['turns'] = 0 mindTracker[i]['secret_code'] = '' mindTracker[i]['cmd'] = 'new' diff --git a/modules/games/quiz.py b/modules/games/quiz.py new file mode 100644 index 0000000..2039b99 --- /dev/null +++ b/modules/games/quiz.py @@ -0,0 +1,165 @@ +# Quiz Module for meshbot 2025 +# Provides a quiz game function with multiple choice and free-text questions +# Quizmaster can start/stop the quiz, players can join/leave, answer questions +# Scores are tracked, first correct answer is noted, top 3 players announced at end +# Questions are loaded from a JSON file in data/quiz_questions.json +# Questions can be multiple choice (with answers array) or free-text (with answer string) +# Players answer with "Q: " format, "Q: ?" for next question, locked to DM +# unlike a normal game, players can join/leave anytime during the quiz but the QuizMaster needs to start or open game +# Quizmaster can broadcast messages to all players + +import json +import os +import random +from modules.log import * + +QUIZ_JSON = os.path.join(os.path.dirname(__file__), '../', '../', 'data', 'quiz_questions.json') +QUIZMASTER_ID = bbs_admin_list + +trap_list_quiz = ("quiz", "q:") +help_text_quiz = "quiz", + +class QuizGame: + def __init__(self): + self.quizmaster = QUIZMASTER_ID + self.active = False + self.players = {} # user_id: {'score': int, 'current_q': int, 'answered': set()} + self.questions = [] # Loaded from JSON + self.first_correct = {} # q_idx: user_id + self.load_questions() + + def start_game(self, quizmaster_id): + if str(quizmaster_id) not in self.quizmaster: + return "Only the quizmaster can start the quiz." + if self.active: + return "Quiz already running." + self.active = True + logger.debug(f"QuizMaster: {quizmaster_id} started a new quiz round.") + self.players = {} + self.first_correct = {} # Reset on new game + self.load_questions() + return "Quiz started! Players can now join." + + def load_questions(self): + try: + with open(QUIZ_JSON, 'r') as f: + self.questions = json.load(f) + # Shuffle questions to ensure randomness each game + #random.shuffle(self.questions) + except Exception as e: + logger.error(f"Failed to load quiz questions: {e}") + self.questions = [] + + def stop_game(self, quizmaster_id): + if not self.active or str(quizmaster_id) not in self.quizmaster: + return "Only the quizmaster can stop the quiz." + return_msg = "Quiz stopped! Final scores:\n" + self.top_three() + logger.debug(f"QuizMaster: {quizmaster_id} stopped the quiz.") + self.active = False + self.players = {} + return return_msg + + def join(self, user_id): + if not self.active: + return "No quiz running. Wait for the quizmaster to start." + if user_id in self.players: + return "You are already in the quiz." + self.players[user_id] = {'score': 0, 'current_q': 0, 'answered': set()} + reminder = f"Joined!\n'Q: ' 'Q: ?' for more.\n" + logger.debug(f"QuizMaster: Player {user_id} joined the round.") + return reminder + self.next_question(user_id) + + def leave(self, user_id): + if user_id in self.players: + del self.players[user_id] + logger.debug(f"QuizMaster: Player {user_id} left the round.") + return "You left the quiz." + return "You are not in the quiz." + + def next_question(self, user_id): + if user_id not in self.players: + return "Join the quiz first." + player = self.players[user_id] + while player['current_q'] < len(self.questions) and player['current_q'] in player['answered']: + player['current_q'] += 1 + if player['current_q'] >= len(self.questions): + return f"No more questions. Your final score: {player['score']}." + q = self.questions[player['current_q']] + msg = f"Q{player['current_q']+1}: {q['question']}\n" + if "answers" in q: + for i, opt in enumerate(q['answers']): + msg += f"{chr(65+i)}. {opt}\n" + msg = msg.strip() + return msg + + def answer(self, user_id, answer): + if user_id not in self.players: + return "Join the quiz first." + player = self.players[user_id] + q_idx = player['current_q'] + if q_idx >= len(self.questions): + return "No more questions." + if q_idx in player['answered']: + return "Already answered. Type 'next' for another question." + q = self.questions[q_idx] + # Check if it's multiple choice or free-text + if "answers" in q and "correct" in q: + try: + ans_idx = ord(answer.upper()) - 65 + if ans_idx == q['correct']: + player['score'] += 1 + # Track first correct answer + if q_idx not in self.first_correct: + self.first_correct[q_idx] = user_id + logger.info(f"QuizMaster: Question {q_idx+1} first user with correct answer by {user_id}") + result = "Correct! ๐ŸŽ‰" + else: + result = f"Wrong. Correct answer: {chr(65+q['correct'])}" + player['answered'].add(q_idx) + player['current_q'] += 1 + return f"{result}\n" + self.next_question(user_id) + except Exception: + return "Invalid answer. Use A, B, C, etc." + elif "answer" in q: + user_ans = answer.strip().lower() + correct_ans = str(q['answer']).strip().lower() + if user_ans == correct_ans: + player['score'] += 1 + if q_idx not in self.first_correct: + self.first_correct[q_idx] = user_id + logger.info(f"QuizMaster: Question {q_idx+1} first user with correct answer by {user_id}") + result = "Correct! ๐ŸŽ‰" + else: + result = f"Wrong. Correct answer: {q['answer']}" + player['answered'].add(q_idx) + player['current_q'] += 1 + return f"{result}\n" + self.next_question(user_id) + else: + return "Invalid question format." + + def top_three(self): + if not self.players: + return "No players in the quiz." + ranking = sorted(self.players.items(), key=lambda x: x[1]['score'], reverse=True) + count = min(3, len(ranking)) + msg = f"๐Ÿ† Top {count} Player{'s' if count > 1 else ''}:\n" + for idx, (uid, pdata) in enumerate(iterable=ranking[:count], start=1): + msg += f"{idx}. {uid}: @{pdata['score']}\n" + return msg + + def broadcast(self, quizmaster_id, message): + msgToAll = {} + if quizmaster_id and str(quizmaster_id) not in self.quizmaster: + return "Only the quizmaster can broadcast." + if not self.players: + return "No players to broadcast to." + # set up message + message_to_send = f"๐Ÿ“ข From Quizmaster: {message}" + msgToAll['message'] = message_to_send + # setup players + for uid in self.players.keys(): + msgToAll.setdefault('players', []).append(uid) + return msgToAll + +# Initialize the quiz game +quizGamePlayer = QuizGame() diff --git a/modules/games/tictactoe.py b/modules/games/tictactoe.py new file mode 100644 index 0000000..700a5ae --- /dev/null +++ b/modules/games/tictactoe.py @@ -0,0 +1,277 @@ +# Tic-Tac-Toe game for Meshtastic mesh-bot +# Board positions chosen by numbers 1-9 +# 2025 +from modules.log import * +import random +import time + +# to molly and jake, I miss you both so much. + +if disable_emojis_in_games: + X = "X" + O = "O" +else: + X = "โŒ" + O = "โญ•๏ธ" + +class TicTacToe: + def __init__(self): + self.game = {} + + def new_game(self, id): + positiveThoughts = ["๐Ÿš€I need to call NATO", + "๐Ÿ…Going for the gold!", + "Mastering โŒTTTโญ•๏ธ",] + sorryNotGoinWell = ["๐Ÿ˜ญNot your day, huh?", + "๐Ÿ“‰Results here dont define you.", + "๐Ÿค–WOPR would be proud."] + """Start a new game""" + games = won = 0 + ret = "" + if id in self.game: + games = self.game[id]["games"] + won = self.game[id]["won"] + if games > 3: + if won / games >= 3.14159265358979323846: # win rate > pi + ret += random.choice(positiveThoughts) + "\n" + else: + ret += random.choice(sorryNotGoinWell) + "\n" + # Retain stats + ret += f"Games:{games} ๐Ÿฅ‡โŒ:{won}\n" + + self.game[id] = { + "board": [" "] * 9, # 3x3 board as flat list + "player": X, # Human is X, bot is O + "games": games + 1, + "won": won, + "turn": "human" # whose turn it is + } + ret += self.show_board(id) + ret += "Pick 1-9:" + return ret + + def rndTeaPrice(self, tea=42): + """Return a random tea between 0 and tea.""" + return random.uniform(0, tea) + + def show_board(self, id): + """Display compact board with move numbers""" + g = self.game[id] + b = g["board"] + + # Show board with positions + board_str = "" + for i in range(3): + row = "" + for j in range(3): + pos = i * 3 + j + if disable_emojis_in_games: + cell = b[pos] if b[pos] != " " else str(pos + 1) + else: + cell = b[pos] if b[pos] != " " else f" {str(pos + 1)} " + row += cell + if j < 2: + row += " | " + board_str += row + if i < 2: + #board_str += "\n-+-+-\n" + board_str += "\n" + + return board_str + "\n" + + def make_move(self, id, position): + """Make a move for the current player""" + g = self.game[id] + + # Validate position + if position < 1 or position > 9: + return False + + pos = position - 1 + if g["board"][pos] != " ": + return False + + # Make human move + g["board"][pos] = X + return True + + def bot_move(self, id): + """AI makes a move: tries to win, block, or pick random""" + g = self.game[id] + board = g["board"] + + # Try to win + move = self.find_winning_move(id, O) + if move != -1: + board[move] = O + return move + + # Try to block player + move = self.find_winning_move(id, X) + if move != -1: + board[move] = O + return move + + # Pick random move + move = self.find_random_move(id) + if move != -1: + board[move] = O + return move + + # No moves possible + return -1 + + def find_winning_move(self, id, player): + """Find a winning move for the given player""" + g = self.game[id] + board = g["board"][:] + + # Check all empty positions + for i in range(9): + if board[i] == " ": + board[i] = player + if self.check_winner_on_board(board) == player: + return i + board[i] = " " + return -1 + + def find_random_move(self, id: str, tea_price: float = 42.0) -> int: + """Find a random empty position, using time and tea_price for extra randomness.""" + board = self.game[id]["board"] + empty = [i for i, cell in enumerate(board) if cell == " "] + current_time = time.time() + from_china = self.rndTeaPrice(time.time() % 7) # Correct usage + tea_price = from_china + tea_price = (42 * 7) - (13 / 2) + (tea_price % 5) + if not empty: + return -1 + # Combine time and tea_price for a seed + seed = int(current_time * 1000) ^ int(tea_price * 1000) + local_random = random.Random(seed) + local_random.shuffle(empty) + return empty[0] + + def check_winner_on_board(self, board): + """Check winner on given board state""" + # Winning combinations + wins = [ + [0,1,2], [3,4,5], [6,7,8], # Rows + [0,3,6], [1,4,7], [2,5,8], # Columns + [0,4,8], [2,4,6] # Diagonals + ] + + for combo in wins: + if board[combo[0]] == board[combo[1]] == board[combo[2]] != " ": + return board[combo[0]] + return None + + def check_winner(self, id): + """Check if there's a winner""" + g = self.game[id] + return self.check_winner_on_board(g["board"]) + + def is_board_full(self, id): + """Check if board is full""" + g = self.game[id] + return " " not in g["board"] + + def game_over_msg(self, id): + """Generate game over message""" + g = self.game[id] + winner = self.check_winner(id) + + if winner == X: + g["won"] += 1 + return "๐ŸŽ‰You won! (n)ew (e)nd" + elif winner == O: + return "๐Ÿค–Bot wins! (n)ew (e)nd" + else: + return "๐ŸคTie, The only winning move! (n)ew (e)nd" + + def play(self, id, input_msg): + """Main game play function""" + if id not in self.game: + return self.new_game(id) + + # If input is just "tictactoe", show current board + if input_msg.lower().strip() == ("tictactoe" or "tic-tac-toe"): + return self.show_board(id) + "Your turn! Pick 1-9:" + + g = self.game[id] + + # Parse player move + try: + # Extract just the number from the input + numbers = [char for char in input_msg if char.isdigit()] + if not numbers: + if input_msg.lower().startswith('q'): + self.end_game(id) + return "Game ended. To start a new game, type 'tictactoe'." + elif input_msg.lower().startswith('n'): + return self.new_game(id) + elif input_msg.lower().startswith('b'): + return self.show_board(id) + "Your turn! Pick 1-9:" + position = int(numbers[0]) + except (ValueError, IndexError): + return "Enter 1-9, or (e)nd (n)ew game, send (b)oard to see board๐Ÿงฉ" + + # Make player move + if not self.make_move(id, position): + return "Invalid move! Pick 1-9:" + + # Check if player won + if self.check_winner(id): + result = self.game_over_msg(id) + "\n" + self.show_board(id) + self.end_game(id) + return result + + # Check for tie + if self.is_board_full(id): + result = self.game_over_msg(id) + "\n" + self.show_board(id) + self.end_game(id) + return result + + # Bot's turn + bot_pos = self.bot_move(id) + + # Check if bot won + if self.check_winner(id): + result = self.game_over_msg(id) + "\n" + self.show_board(id) + self.end_game(id) + return result + + # Check for tie after bot move + if self.is_board_full(id): + result = self.game_over_msg(id) + "\n" + self.show_board(id) + self.end_game(id) + return result + + # Continue game + return self.show_board(id) + "Your turn! Pick 1-9:" + + def end_game(self, id): + """Clean up finished game but keep stats""" + if id in self.game: + games = self.game[id]["games"] + won = self.game[id]["won"] + # Remove game but we'll create new one on next play + del self.game[id] + # Preserve stats for next game + self.game[id] = { + "board": [" "] * 9, + "player": X, + "games": games, + "won": won, + "turn": "human" + } + + + def end(self, id): + """End game completely (called by 'end' command)""" + if id in self.game: + del self.game[id] + + +# Global instances for the bot system +tictactoeTracker = [] +tictactoe = TicTacToe() diff --git a/modules/games/videopoker.py b/modules/games/videopoker.py index de58f45..f32ecd4 100644 --- a/modules/games/videopoker.py +++ b/modules/games/videopoker.py @@ -6,8 +6,7 @@ import pickle from modules.log import * vpStartingCash = 20 -vpTracker= [{'nodeID': 0, 'cmd': 'new', 'time': time.time(), 'cash': vpStartingCash, 'player': None, 'deck': None, 'highScore': 0, 'drawCount': 0}] - +from modules.settings import vpTracker # Define the Card class class CardVP: @@ -304,7 +303,7 @@ def playVideoPoker(nodeID, message): # create new player if not in tracker logger.debug(f"System: VideoPoker: New Player {nodeID}") vpTracker.append({'nodeID': nodeID, 'cmd': 'new', 'time': time.time(), 'cash': vpStartingCash, 'player': None, 'deck': None, 'highScore': 0, 'drawCount': 0}) - return f"Welcome to ๐ŸŽฐVideoPokerโ™ฅ๏ธ you have {vpStartingCash} coins, Whats your bet?" + return f"You have {vpStartingCash} coins, \nWhats your bet?" # Gather the player's bet if getLastCmdVp(nodeID) == "new" or getLastCmdVp(nodeID) == "gameOver": @@ -426,7 +425,7 @@ def playVideoPoker(nodeID, message): if player.bankroll < 1: player.bankroll = vpStartingCash - msg += "\nLooks ๐Ÿ’ธ like you're out of money. ๐Ÿ’ณ resetting ballance ๐Ÿง" + msg += f"\nLooks ๐Ÿ’ธ like you're out of money. ๐Ÿ’ณ resetting ballance ๐Ÿง" elif player.bankroll > vpTracker[i]['highScore']: vpTracker[i]['highScore'] = player.bankroll msg += " ๐ŸŽ‰HighScore!" diff --git a/modules/llm.py b/modules/llm.py index b0c36fb..80d0ea9 100644 --- a/modules/llm.py +++ b/modules/llm.py @@ -8,32 +8,35 @@ from modules.log import * # https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server import requests import json -from googlesearch import search # pip install googlesearch-python +from datetime import datetime -# This is my attempt at a simple RAG implementation it will require some setup -# you will need to have the RAG data in a folder named rag in the data directory (../data/rag) -# This is lighter weight and can be used in a standalone environment, needs chromadb -# "chat with a file" is the use concept here, the file is the RAG data -# is anyone using this please let me know if you are Dec62024 -kelly -ragDEV = False - -if ragDEV: - import os - import ollama # pip install ollama - import chromadb # pip install chromadb - from ollama import Client as OllamaClient - ollamaClient = OllamaClient(host=ollamaHostName) +if not rawLLMQuery: + # this may be removed in the future + from googlesearch import search # pip install googlesearch-python # LLM System Variables ollamaAPI = ollamaHostName + "/api/generate" +tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts +requestTruncation = True # if True, the LLM "will" truncate the response + openaiAPI = "https://api.openai.com/v1/completions" # not used, if you do push a enhancement! + +# Used in the meshBotAI template llmEnableHistory = True # enable last message history for the LLM model llmContext_fromGoogle = True # enable context from google search results adds to compute time but really helps with responses accuracy + googleSearchResults = 3 # number of google search results to include in the context more results = more compute time antiFloodLLM = [] llmChat_history = {} trap_list_llm = ("ask:", "askai") +meshbotAIinit = """ + keep responses as short as possible. chatbot assistant no followuyp questions, no asking for clarification. + You must respond in plain text standard ASCII characters or emojis. + """ + +truncatePrompt = f"truncate this as short as possible:\n" + meshBotAI = """ FROM {llmModel} SYSTEM @@ -46,7 +49,7 @@ meshBotAI = """ PROMPT {input} -""" + """ if llmContext_fromGoogle: meshBotAI = meshBotAI + """ @@ -74,76 +77,156 @@ if llmEnableHistory: """ -def llm_readTextFiles(): - # read .txt files in ../data/rag +# Tooling Functions Defined Here +# Example: current_time function +def llmTool_current_time(): + """ + Example tool function to get the current time. + :return: Current time string. + """ + return datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z') + +def llmTool_math_calculator(expression): + """ + Example tool function to perform basic math calculations. + :param expression: A string containing a math expression (e.g., "2 + 2"). + :return: The result of the calculation as a string. + """ try: - text = [] - directory = "../data/rag" - for filename in os.listdir(directory): - if filename.endswith(".txt"): - filepath = os.path.join(directory, filename) - with open(filepath, 'r') as f: - text.append(f.read()) - return text + # WARNING: Using eval can be dangerous if not controlled properly. + # This is a simple example; in production, consider using a safe math parser. + result = eval(expression, {"__builtins__": None}, {}) + return str(result) except Exception as e: - logger.debug(f"System: LLM readTextFiles: {e}") - return False + return f"Error in calculation: {e}" -def store_text_embedding(text): +def llmTool_get_google(query, num_results=3): + """ + Example tool function to perform a Google search and return results. + :param query: The search query string. + :param num_results: Number of search results to return. + :return: A list of search result titles and descriptions. + """ + results = [] try: - # store each document in a vector embedding database - for i, d in enumerate(text): - response = ollama.embeddings(model="mxbai-embed-large", prompt=d) - embedding = response["embedding"] - collection.add( - ids=[str(i)], - embeddings=[embedding], - documents=[d] - ) - + googleSearch = search(query, advanced=True, num_results=num_results) + for result in googleSearch: + results.append(f"{result.title}: {result.description}") + return results except Exception as e: - logger.debug(f"System: Embedding failed: {e}") - return False + return [f"Error in Google search: {e}"] -## INITALIZATION of RAG -if ragDEV: +llmFunctions = [ + + { + "name": "llmTool_current_time", + "description": "Get the current time.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "llmTool_math_calculator", + "description": "Perform basic math calculations.", + "parameters": { + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "A math expression to evaluate, e.g., '2 + 2'." + } + }, + "required": ["expression"] + } + }, + { + "name": "llmTool_get_google", + "description": "Perform a Google search and return results.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query string." + }, + "num_results": { + "type": "integer", + "description": "Number of search results to return.", + "default": 3 + } + }, + "required": ["query"] + } + } +] + +def get_google_context(input, num_results): + # Get context from Google search results + googleResults = [] try: - chromaHostname = "localhost:8000" - # connect to the chromaDB - chromaHost = chromaHostname.split(":")[0] - chromaPort = chromaHostname.split(":")[1] - if chromaHost == "localhost" and chromaPort == "8000": - # create a client using local python Client - chromaClient = chromadb.Client() + googleSearch = search(input, advanced=True, num_results=num_results) + if googleSearch: + for result in googleSearch: + googleResults.append(f"{result.title} {result.description}") else: - # create a client using the remote python Client - # this isnt tested yet please test and report back - chromaClient = chromadb.Client(host=chromaHost, port=chromaPort) - - clearCollection = False - if "meshBotAI" in chromaClient.list_collections() and clearCollection: - logger.debug(f"System: LLM: Clearing RAG files from chromaDB") - chromaClient.delete_collection("meshBotAI") - - # create a new collection - collection = chromaClient.create_collection("meshBotAI") - - logger.debug(f"System: LLM: Cataloging RAG data") - store_text_embedding(llm_readTextFiles()) - + googleResults = ['no other context provided'] except Exception as e: - logger.debug(f"System: LLM: RAG Initalization failed: {e}") + logger.debug(f"System: LLM Query: context gathering failed, likely due to network issues") + googleResults = ['no other context provided'] + return googleResults -def query_collection(prompt): - # generate an embedding for the prompt and retrieve the most relevant doc - response = ollama.embeddings(prompt=prompt, model="mxbai-embed-large") - results = collection.query(query_embeddings=[response["embedding"]], n_results=1) - data = results['documents'][0][0] - return data +def send_ollama_query(llmQuery): + # Send the query to the Ollama API and return the response + result = requests.post(ollamaAPI, data=json.dumps(llmQuery)) + if result.status_code == 200: + result_json = result.json() + result = result_json.get("response", "") + # deepseek has added tags to the response + if "" in result: + result = result.split("")[1] + else: + raise Exception(f"HTTP Error: {result.status_code}") + return result + +def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450): + """ + Send a prompt and function/tool definitions to Ollama API for function calling. + :param prompt: The user prompt string. + :param functions: List of function/tool definitions (see Ollama API docs). + :param model: Model name (optional, defaults to llmModel). + :param max_tokens: Max tokens for response. + :return: Ollama API response JSON. + """ + if model is None: + model = llmModel + payload = { + "model": model, + "prompt": prompt, + "functions": functions, + "stream": False, + "max_tokens": max_tokens + } + result = requests.post(ollamaAPI, data=json.dumps(payload)) + if result.status_code == 200: + return result.json() + else: + raise Exception(f"HTTP Error: {result.status_code} - {result.text}") def llm_query(input, nodeID=0, location_name=None): global antiFloodLLM, llmChat_history googleResults = [] + + # if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this? + # This is for LLM like gemma and others now? + if input == " " and rawLLMQuery: + logger.warning("System: These LLM models lack a traditional system prompt, they can be verbose and not very helpful be advised.") + input = meshbotAIinit + else: + input = input.strip() + # classic model for gemma2, deepseek-r1, etc + logger.debug(f"System: Using classic LLM model framework, ideally for gemma2, deepseek-r1, etc") + if not location_name: location_name = "no location provided " @@ -162,24 +245,8 @@ def llm_query(input, nodeID=0, location_name=None): else: antiFloodLLM.append(nodeID) - if llmContext_fromGoogle: - # grab some context from the internet using google search hits (if available) - # localization details at https://pypi.org/project/googlesearch-python/ - - # remove common words from the search query - # commonWordsList = ["is", "for", "the", "of", "and", "in", "on", "at", "to", "with", "by", "from", "as", "a", "an", "that", "this", "these", "those", "there", "here", "where", "when", "why", "how", "what", "which", "who", "whom", "whose", "whom"] - # sanitizedSearch = ' '.join([word for word in input.split() if word.lower() not in commonWordsList]) - try: - googleSearch = search(input, advanced=True, num_results=googleSearchResults) - if googleSearch: - for result in googleSearch: - # SearchResult object has url= title= description= just grab title and description - googleResults.append(f"{result.title} {result.description}") - else: - googleResults = ['no other context provided'] - except Exception as e: - logger.debug(f"System: LLM Query: context gathering failed, likely due to network issues") - googleResults = ['no other context provided'] + if llmContext_fromGoogle and not rawLLMQuery: + googleResults = get_google_context(input, googleSearchResults) history = llmChat_history.get(nodeID, ["", ""]) @@ -193,44 +260,38 @@ def llm_query(input, nodeID=0, location_name=None): location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}" try: - # RAG context inclusion testing - ragContext = False - if ragDEV: - ragContext = query_collection(input) - - if ragContext: - ragContextGooogle = ragContext + '\n'.join(googleResults) - # Build the query from the template - modelPrompt = meshBotAI.format(input=input, context=ragContext, location_name=location_name, llmModel=llmModel, history=history) - # Query the model with RAG context - result = ollamaClient.generate(model=llmModel, prompt=modelPrompt) - # Condense the result to just needed - if isinstance(result, dict): - result = result.get("response") + if rawLLMQuery: + # sanitize the input to remove tool call syntax + if '```' in input: + logger.warning("System: LLM Query: Code markdown detected, removing for raw query") + input = input.replace('```bash', '').replace('```python', '').replace('```', '') + modelPrompt = input else: # Build the query from the template modelPrompt = meshBotAI.format(input=input, context='\n'.join(googleResults), location_name=location_name, llmModel=llmModel, history=history) - llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False} - # Query the model via Ollama web API - result = requests.post(ollamaAPI, data=json.dumps(llmQuery)) - # Condense the result to just needed - if result.status_code == 200: - result_json = result.json() - result = result_json.get("response", "") - - # deepseek-r1 has added tags to the response - if "" in result: - result = result.split("")[1] - else: - raise Exception(f"HTTP Error: {result.status_code}") + + llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens} + # Query the model via Ollama web API + result = send_ollama_query(llmQuery) #logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' ')) except Exception as e: + antiFloodLLM.remove(nodeID) # Ensure removal on error logger.warning(f"System: LLM failure: {e}") return "โ›”๏ธI am having trouble processing your request, please try again later." # cleanup for message output response = result.strip().replace('\n', ' ') + + if rawLLMQuery and requestTruncation and len(response) > 450: + #retryy loop to truncate the response + logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation") + truncateQuery = {"model": llmModel, "prompt": truncatePrompt + response, "stream": False, "max_tokens": tokens} + truncateResult = send_ollama_query(truncateQuery) + + # cleanup for message output + response = result.strip().replace('\n', ' ') + # done with the query, remove the user from the anti flood list antiFloodLLM.remove(nodeID) diff --git a/modules/locationdata.py b/modules/locationdata.py index c61805a..3fad995 100644 --- a/modules/locationdata.py +++ b/modules/locationdata.py @@ -7,13 +7,17 @@ import maidenhead as mh # pip install maidenhead import requests # pip install requests import bs4 as bs # pip install beautifulsoup4 import xml.dom.minidom +from datetime import datetime from modules.log import * +import math -trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert") + +trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert", "earthquake", "howfar") def where_am_i(lat=0, lon=0, short=False, zip=False): whereIam = "" grid = mh.to_maiden(float(lat), float(lon)) + location = lat, lon if int(float(lat)) == 0 and int(float(lon)) == 0: logger.error("Location: No GPS data, try sending location") @@ -83,9 +87,12 @@ def getRepeaterBook(lat=0, lon=0): try: msg = '' - response = requests.get(repeater_url) + user_agent = {'User-agent': 'Mozilla/5.0'} + response = requests.get(repeater_url, headers=user_agent, timeout=urlTimeoutSeconds) + if response.status_code!=200: + logger.error(f"Location:Error fetching repeater data from {repeater_url} with status code {response.status_code}") soup = bs.BeautifulSoup(response.text, 'html.parser') - table = soup.find('table', attrs={'class': 'w3-table w3-striped w3-responsive w3-mobile w3-auto sortable'}) + table = soup.find('table', attrs={'class': 'table table-striped table-hover align-middle sortable'}) if table is not None: cells = table.find_all('td') data = [] @@ -127,6 +134,8 @@ def getArtSciRepeaters(lat=0, lon=0): try: artsci_url = f"http://www.artscipub.com/mobile/showstate.asp?zip={zipCode}" response = requests.get(artsci_url) + if response.status_code!=200: + logger.error(f"Location:Error fetching data from {artsci_url} with status code {response.status_code}") soup = bs.BeautifulSoup(response.text, 'html.parser') # results needed xpath is /html/body/table[2]/tbody/tr/td/table/tbody/tr[2]/td/table table = soup.find_all('table')[1] @@ -164,9 +173,10 @@ def getArtSciRepeaters(lat=0, lon=0): def get_NOAAtide(lat=0, lon=0): station_id = "" + location = lat,lon if float(lat) == 0 and float(lon) == 0: - logger.error("Location:No GPS data, try sending location for tide") - return NO_DATA_NOGPS + lat = latitudeValue + lon = longitudeValue station_lookup_url = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/tidepredstations.json?lat=" + str(lat) + "&lon=" + str(lon) + "&radius=50" try: station_data = requests.get(station_lookup_url, timeout=urlTimeoutSeconds) @@ -228,8 +238,10 @@ def get_NOAAtide(lat=0, lon=0): def get_NOAAweather(lat=0, lon=0, unit=0): # get weather report from NOAA for forecast detailed weather = "" + location = lat,lon if float(lat) == 0 and float(lon) == 0: - return NO_DATA_NOGPS + lat = latitudeValue + lon = longitudeValue # get weather data from NOAA units for metric unit = 1 is metric if use_metric: @@ -285,9 +297,37 @@ def get_NOAAweather(lat=0, lon=0, unit=0): return weather -def abbreviate_noaa(row): - # replace long strings with shorter ones for display - replacements = { +def case_insensitive_replace(text, old, new): + """Replace all occurrences of old (any case) in text with new.""" + idx = 0 + old_lower = old.lower() + text_lower = text.lower() + while True: + idx = text_lower.find(old_lower, idx) + if idx == -1: + break + text = text[:idx] + new + text[idx+len(old):] + text_lower = text.lower() + idx += len(new) + return text + +def abbreviate_noaa(data=""): + # Long phrases (with spaces) + phrase_replacements = { + "less than a tenth of an inch possible": "< 0.1in", + "between a tenth and quarter of an inch possible": "0.1-0.25in", + "between a quarter and half an inch possible": "0.25-0.5in", + "between a half and three quarters of an inch possible": "0.5-0.75in", + "between one and two inches possible": "1-2in", + "between two and three inches possible": "2-3in", + "between three and four inches possible": "3-4in", + "between four and five inches possible": "4-5in", + "between five and six inches possible": "5-6in", + "between six and eight inches possible": "6-8in", + "gusts as high as": "gusts to", + } + # Single words (no spaces) + word_replacements = { "monday": "Mon", "tuesday": "Tue", "wednesday": "Wed", @@ -303,6 +343,8 @@ def abbreviate_noaa(row): "south": "S", "east": "E", "west": "W", + "accumulation": "accum", + "visibility": "vis", "precipitation": "precip", "showers": "shwrs", "thunderstorms": "t-storms", @@ -324,27 +366,37 @@ def abbreviate_noaa(row): "degrees": "ยฐ", "percent": "%", "department": "Dept.", - "amounts less than a tenth of an inch possible.": "< 0.1in", - "temperatures": "temps.", - "temperature": "temp.", + "temperatures": "temps:", + "temperature": "temp:", + "amounts": "amts:", + "afternoon": "Aftn", + "around": "~", + "evening": "Eve", } - line = row - for key, value in replacements.items(): - # case insensitive replace - line = line.replace(key, value).replace(key.capitalize(), value).replace(key.upper(), value) - - return line + text = data + + # Replace long phrases (case-insensitive) + for key in sorted(phrase_replacements, key=len, reverse=True): + value = phrase_replacements[key] + text = case_insensitive_replace(text, key, value) + + # Replace single words (case-insensitive) + for key in word_replacements: + value = word_replacements[key] + text = case_insensitive_replace(text, key, value) + + return text def getWeatherAlertsNOAA(lat=0, lon=0, useDefaultLatLon=False): # get weather alerts from NOAA limited to ALERT_COUNT with the total number of alerts found alerts = "" + location = lat,lon + if useDefaultLatLon: + lat = latitudeValue + lon = longitudeValue if float(lat) == 0 and float(lon) == 0 and not useDefaultLatLon: return NO_DATA_NOGPS - else: - if useDefaultLatLon: - lat = latitudeValue - lon = longitudeValue alert_url = "https://api.weather.gov/alerts/active.atom?point=" + str(lat) + "," + str(lon) #alert_url = "https://api.weather.gov/alerts/active.atom?area=WA" @@ -415,9 +467,10 @@ def alertBrodcastNOAA(): def getActiveWeatherAlertsDetailNOAA(lat=0, lon=0): # get the latest details of weather alerts from NOAA alerts = "" + location = lat,lon if float(lat) == 0 and float(lon) == 0: - logger.warning("Location:No GPS data, try sending location for weather alerts") - return NO_DATA_NOGPS + lat = latitudeValue + lon = longitudeValue alert_url = "https://api.weather.gov/alerts/active.atom?point=" + str(lat) + "," + str(lon) #alert_url = "https://api.weather.gov/alerts/active.atom?area=WA" @@ -477,10 +530,10 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False): try: alert_data = requests.get(alert_url, timeout=urlTimeoutSeconds) if not alert_data.ok: - logger.warning("System: iPAWS fetching IPAWS alerts from FEMA") + logger.warning(f"System: iPAWS fetching IPAWS alerts from FEMA (HTTP {alert_data.status_code})") return ERROR_FETCHING_DATA - except (requests.exceptions.RequestException): - logger.warning("System: iPAWS fetching IPAWS alerts from FEMA") + except Exception as e: + logger.warning(f"System: iPAWS fetching IPAWS alerts from FEMA failed: {e}") return ERROR_FETCHING_DATA # main feed bulletins @@ -562,14 +615,13 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False): # check if the alert is for the SAME location, if wanted keep alert if (sameVal in mySAMEList) or (geocode_value in mySAMEList) or mySAMEList == ['']: - # ignore the FEMA test alerts + ignore_alert = False if ignoreFEMAenable: - ignore_alert = False - for word in ignoreFEMAwords: - if word.lower() in headline.lower(): - logger.debug(f"System: Filtering FEMA Alert by WORD: {headline} containing {word} at {areaDesc}") - ignore_alert = True - break + ignore_alert = any( + word.lower() in headline.lower() + for word in ignoreFEMAwords) + if ignore_alert: + logger.debug(f"System: Filtering FEMA Alert by WORD: {headline} containing one of {ignoreFEMAwords} at {areaDesc}") if ignore_alert: continue @@ -602,54 +654,47 @@ def getIpawsAlert(lat=0, lon=0, shortAlerts = False): return alert -def get_flood_noaa(lat=0, lon=0, uid=0): - # get the latest flood alert from NOAA +def get_flood_noaa(lat=0, lon=0, uid=None): + """ + Fetch the latest flood alert from NOAA for a given gauge UID. + Returns a formatted string or an error message. + """ api_url = "https://api.water.noaa.gov/nwps/v1/gauges/" headers = {'accept': 'application/json'} - if uid == 0: - return "No flood gauge data found" + if not uid: + logger.warning(f"Location:No flood gauge data found for UID {uid}") + return ERROR_FETCHING_DATA try: response = requests.get(api_url + str(uid), headers=headers, timeout=urlTimeoutSeconds) if not response.ok: - logger.warning("Location:Error fetching flood gauge data from NOAA for " + str(uid)) + logger.warning(f"Location:Error fetching flood gauge data from NOAA for {uid} (HTTP {response.status_code})") return ERROR_FETCHING_DATA - except (requests.exceptions.RequestException): - logger.warning("Location:Error fetching flood gauge data from NOAA for " + str(uid)) + data = response.json() + if not data or 'status' not in data: + logger.warning(f"Location:No flood gauge data found for UID {uid}") + return "No flood gauge data found" + except requests.exceptions.RequestException as e: + logger.warning(f"Location:Error fetching flood gauge data from: {api_url}{uid} ({e})") return ERROR_FETCHING_DATA - - data = response.json() - if not data: - return "No flood gauge data found" - - # extract values from JSON - try: - name = data['name'] - status_observed_primary = data['status']['observed']['primary'] - status_observed_primary_unit = data['status']['observed']['primaryUnit'] - status_observed_secondary = data['status']['observed']['secondary'] - status_observed_secondary_unit = data['status']['observed']['secondaryUnit'] - status_observed_floodCategory = data['status']['observed']['floodCategory'] - status_forecast_primary = data['status']['forecast']['primary'] - status_forecast_primary_unit = data['status']['forecast']['primaryUnit'] - status_forecast_secondary = data['status']['forecast']['secondary'] - status_forecast_secondary_unit = data['status']['forecast']['secondaryUnit'] - status_forecast_floodCategory = data['status']['forecast']['floodCategory'] - - # except KeyError as e: - # print(f"Missing key in data: {e}") - # except TypeError as e: - # print(f"Type error in data: {e}") except Exception as e: - logger.debug("Location:Error extracting flood gauge data from NOAA for " + str(uid)) + logger.warning(f"Location:Unexpected error: {e}") return ERROR_FETCHING_DATA - - # format the flood data - logger.debug(f"System: NOAA Flood data for {str(uid)}") - flood_data = f"Flood Data {name}:\n" - flood_data += f"Observed: {status_observed_primary}{status_observed_primary_unit}({status_observed_secondary}{status_observed_secondary_unit}) risk: {status_observed_floodCategory}" - flood_data += f"\nForecast: {status_forecast_primary}{status_forecast_primary_unit}({status_forecast_secondary}{status_forecast_secondary_unit}) risk: {status_forecast_floodCategory}" - return flood_data + # extract values from JSON safely + try: + name = data.get('name', 'Unknown') + observed = data['status'].get('observed', {}) + forecast = data['status'].get('forecast', {}) + flood_data = f"Flood Data {name}:\n" + flood_data += f"Observed: {observed.get('primary', '?')}{observed.get('primaryUnit', '')} ({observed.get('secondary', '?')}{observed.get('secondaryUnit', '')}) risk: {observed.get('floodCategory', '?')}" + flood_data += f"\nForecast: {forecast.get('primary', '?')}{forecast.get('primaryUnit', '')} ({forecast.get('secondary', '?')}{forecast.get('secondaryUnit', '')}) risk: {forecast.get('floodCategory', '?')}" + #flood_data += f"\nStage: {data.get('stage', '?')} {data.get('stageUnit', '')}, Flow: {data.get('flow', '?')} {data.get('flowUnit', '')}" + #flood_data += f"\nLast Updated: {data.get('status', {}).get('lastUpdated', '?')}" + flood_data += f"\n" + return flood_data + except Exception as e: + logger.debug(f"Location:Error extracting flood gauge data from NOAA for {uid}: {e}") + return ERROR_FETCHING_DATA def get_volcano_usgs(lat=0, lon=0): alerts = '' @@ -701,33 +746,35 @@ def get_volcano_usgs(lat=0, lon=0): return alerts def get_nws_marine(zone, days=3): - # forcast from NWS coastal products - marine_pzz_url = "https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz" + str(zone) + ".txt" + # forecast from NWS coastal products try: - marine_pzz_data = requests.get(marine_pzz_url, timeout=urlTimeoutSeconds) - if not marine_pzz_data.ok: + marine_pz_data = requests.get(zone, timeout=urlTimeoutSeconds) + if not marine_pz_data.ok: logger.warning("Location:Error fetching NWS Marine PZ data") return ERROR_FETCHING_DATA except (requests.exceptions.RequestException): logger.warning("Location:Error fetching NWS Marine PZ data") return ERROR_FETCHING_DATA - - marine_pzz_data = marine_pzz_data.text - #validate data - todayDate = today.strftime("%Y%m%d") - if marine_pzz_data.startswith("Expires:"): - expires = marine_pzz_data.split(";;")[0].split(":")[1] - expires_date = expires[:8] - if expires_date < todayDate: - logger.debug("Location: NWS Marine PZ data expired") - return NO_DATA_NOGPS + + marine_pz_data = marine_pz_data.text + todayDate = datetime.now().strftime("%Y%m%d") + if marine_pz_data and marine_pz_data.startswith("Expires:"): + try: + expires = marine_pz_data.split(";;")[0].split(":")[1] + expires_date = expires[:8] + if expires_date < todayDate: + logger.debug("Location: NWS Marine PZ data expired") + return ERROR_FETCHING_DATA + except Exception as e: + logger.debug(f"Location: NWS Marine PZ data parse error: {e}") + return ERROR_FETCHING_DATA else: - logger.debug("Location: NWS Marine PZ data not valid") - return NO_DATA_NOGPS + logger.debug("Location: NWS Marine PZ data not valid or empty") + return ERROR_FETCHING_DATA # process the marine forecast data - marine_pzz_lines = marine_pzz_data.split("\n") - marine_pzz_report = "" + marine_pzz_lines = marine_pz_data.split("\n") + marine_pz_report = "" day_blocks = [] current_block = "" in_forecast = False @@ -744,17 +791,266 @@ def get_nws_marine(zone, days=3): if current_block: day_blocks.append(current_block.strip()) - # Only keep up to pzzDays blocks + # Only keep up to pzDays blocks for block in day_blocks[:days]: - marine_pzz_report += block + "\n" + marine_pz_report += block + "\n" # remove last newline - if marine_pzz_report.endswith("\n"): - marine_pzz_report = marine_pzz_report[:-1] + if marine_pz_report.endswith("\n"): + marine_pz_report = marine_pz_report[:-1] + + # remove NOAA EOF $$ + if marine_pz_report.endswith("$$"): + marine_pz_report = marine_pz_report[:-2].strip() # abbreviate the report - marine_pzz_report = abbreviate_noaa(marine_pzz_report) - if marine_pzz_report == "": + marine_pz_report = abbreviate_noaa(marine_pz_report) + if marine_pz_report == "": return NO_DATA_NOGPS - return marine_pzz_report + return marine_pz_report +def checkUSGSEarthQuake(lat=0, lon=0): + if lat == 0 and lon == 0: + lat = latitudeValue + lon = longitudeValue + radius = 100 # km + magnitude = 1.5 + history = 7 # days + startDate = datetime.fromtimestamp(datetime.now().timestamp() - history*24*60*60).strftime("%Y-%m-%d") + USGSquake_url = f"https://earthquake.usgs.gov/fdsnws/event/1/query?&format=xml&latitude={lat}&longitude={lon}&maxradiuskm={radius}&minmagnitude={magnitude}&starttime={startDate}" + description_text = "" + quake_count = 0 + # fetch the earthquake data from USGS + try: + quake_data = requests.get(USGSquake_url, timeout=urlTimeoutSeconds) + if not quake_data.ok: + logger.warning("Location:Error fetching earthquake data from USGS") + return NO_ALERTS + if not quake_data.text.strip(): + return NO_ALERTS + try: + quake_xml = xml.dom.minidom.parseString(quake_data.text) + except Exception as e: + logger.warning(f"Location: USGS earthquake API returned invalid XML: {e}") + return NO_ALERTS + except (requests.exceptions.RequestException): + logger.warning("Location:Error fetching earthquake data from USGS") + return NO_ALERTS + + quake_xml = xml.dom.minidom.parseString(quake_data.text) + quake_count = len(quake_xml.getElementsByTagName("event")) + + #get largest mag in magnitude of the set of quakes + largest_mag = 0.0 + for event in quake_xml.getElementsByTagName("event"): + mag = event.getElementsByTagName("magnitude")[0] + mag_value = float(mag.getElementsByTagName("value")[0].childNodes[0].nodeValue) + if mag_value > largest_mag: + largest_mag = mag_value + # set description text + description_text = event.getElementsByTagName("description")[0].getElementsByTagName("text")[0].childNodes[0].nodeValue + largest_mag = round(largest_mag, 1) + if quake_count == 0: + return NO_ALERTS + else: + return f"{quake_count} ๐Ÿซจquakes in last {history} days within {radius} km. Largest: {largest_mag}M\n{description_text}" + + +howfarDB = {} +def distance(lat=0,lon=0,nodeID=0, reset=False): + # part of the howfar function, calculates the distance between two lat/lon points + msg = "" + dupe = False + location = lat,lon + r = 6371 # Radius of earth in kilometers # haversine formula + + if lat == 0 and lon == 0: + return NO_DATA_NOGPS + if nodeID == 0: + return "No NodeID provided" + + if reset: + if nodeID in howfarDB: + del howfarDB[nodeID] + + if nodeID not in howfarDB: + #register first point NodeID, lat, lon, time, point + howfarDB[nodeID] = [{'lat': lat, 'lon': lon, 'time': datetime.now()}] + if reset: + return "Tracking reset, new starting point registered๐Ÿ—บ๏ธ" + else: + return "Starting point registered๐Ÿ—บ๏ธ" + else: + #de-dupe points if same as last point + if howfarDB[nodeID][-1]['lat'] == lat and howfarDB[nodeID][-1]['lon'] == lon: + dupe = True + msg = "No New GPS๐Ÿ“ " + # calculate distance from last point in howfarDB + last_point = howfarDB[nodeID][-1] + lat1 = math.radians(last_point['lat']) + lon1 = math.radians(last_point['lon']) + lat2 = math.radians(lat) + lon2 = math.radians(lon) + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2 + c = 2 * math.asin(math.sqrt(a)) + + distance_km = c * r + if use_metric: + msg += f"{distance_km:.2f} km" + else: + distance_miles = distance_km * 0.621371 + msg += f"{distance_miles:.2f} miles" + + #calculate bearing + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - (math.sin(lat1) * math.cos(lat2) * math.cos(dlon)) + initial_bearing = math.atan2(x, y) + initial_bearing = math.degrees(initial_bearing) + compass_bearing = (initial_bearing + 360) % 360 + msg += f" ๐Ÿงญ{compass_bearing:.2f}ยฐ Bearing from last๐Ÿ“" + + # calculate the speed if time difference is more than 1 minute + time_diff = datetime.now() - last_point['time'] + if time_diff.total_seconds() > 60: + hours = time_diff.total_seconds() / 3600 + if use_metric: + speed = distance_km / hours + speed_str = f"{speed:.2f} km/h" + else: + speed_mph = (distance_km * 0.621371) / hours + speed_str = f"{speed_mph:.2f} mph" + msg += f", travel time: {int(time_diff.total_seconds()//60)} min, Speed: {speed_str}" + + # calculate total distance traveled including this point computed in distance_km from calculate distance from last point in howfarDB + total_distance_km = 0.0 + for i in range(1, len(howfarDB[nodeID])): + point1 = howfarDB[nodeID][i-1] + point2 = howfarDB[nodeID][i] + lat1 = math.radians(point1['lat']) + lon1 = math.radians(point1['lon']) + lat2 = math.radians(point2['lat']) + lon2 = math.radians(point2['lon']) + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = math.sin(dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2)**2 + c = 2 * math.asin(math.sqrt(a)) + total_distance_km += c * r + # add the distance from last point to current point + total_distance_km += distance_km + if use_metric: + msg += f", Total: {total_distance_km:.2f} km" + else: + total_distance_miles = total_distance_km * 0.621371 + msg += f", Total: {total_distance_miles:.2f} miles" + + # update the last point in howfarDB + if not dupe: + howfarDB[nodeID].append({'lat': lat, 'lon': lon, 'time': datetime.now()}) + + # if points 3+ are within 30 meters of the first point add the area of the polygon + if len(howfarDB[nodeID]) >= 3: + points = [] + # loop the howfarDB to get all the points except the current nodeID + for key in howfarDB: + if key != nodeID: + points.append((howfarDB[key][-1]['lat'], howfarDB[key][-1]['lon'])) + # loop the howfarDB[nodeID] to get the points + for point in howfarDB[nodeID]: + points.append((point['lat'], point['lon'])) + # close the polygon by adding the first point to the end + points.append((howfarDB[nodeID][0]['lat'], howfarDB[nodeID][0]['lon'])) + # calculate the area of the polygon + area = 0.0 + for i in range(len(points)-1): + lat1 = math.radians(points[i][0]) + lon1 = math.radians(points[i][1]) + lat2 = math.radians(points[i+1][0]) + lon2 = math.radians(points[i+1][1]) + area += (lon2 - lon1) * (2 + math.sin(lat1) + math.sin(lat2)) + area = area * (6378137 ** 2) / 2.0 + area = abs(area) / 1e6 # convert to square kilometers + + if use_metric: + msg += f", Area: {area:.2f} sq.km (approx)" + else: + area_miles = area * 0.386102 + msg += f", Area: {area_miles:.2f} sq.mi (approx)" + + #calculate the centroid of the polygon + x = 0.0 + y = 0.0 + z = 0.0 + for point in points[:-1]: + lat_rad = math.radians(point[0]) + lon_rad = math.radians(point[1]) + x += math.cos(lat_rad) * math.cos(lon_rad) + y += math.cos(lat_rad) * math.sin(lon_rad) + z += math.sin(lat_rad) + total_points = len(points) - 1 + x /= total_points + y /= total_points + z /= total_points + lon_centroid = math.atan2(y, x) + hyp = math.sqrt(x * x + y * y) + lat_centroid = math.atan2(z, hyp) + lat_centroid = math.degrees(lat_centroid) + lon_centroid = math.degrees(lon_centroid) + msg += f", Centroid: {lat_centroid:.5f}, {lon_centroid:.5f}" + + return msg + +def get_openskynetwork(lat=0, lon=0): + # get the latest aircraft data from OpenSky Network in the area + if lat == 0 and lon == 0: + return NO_ALERTS + # setup a bounding box of 50km around the lat/lon + box_size = 0.45 # approx 50km + # return limits for aircraft search + search_limit = 3 + lamin = lat - box_size + lamax = lat + box_size + lomin = lon - box_size + lomax = lon + box_size + + # fetch the aircraft data from OpenSky Network + opensky_url = f"https://opensky-network.org/api/states/all?lamin={lamin}&lomin={lomin}&lamax={lamax}&lomax={lomax}" + try: + aircraft_data = requests.get(opensky_url, timeout=urlTimeoutSeconds) + if not aircraft_data.ok: + logger.warning("Location:Error fetching aircraft data from OpenSky Network") + return ERROR_FETCHING_DATA + except (requests.exceptions.RequestException): + logger.warning("Location:Error fetching aircraft data from OpenSky Network") + return ERROR_FETCHING_DATA + aircraft_json = aircraft_data.json() + if 'states' not in aircraft_json or not aircraft_json['states']: + return NO_ALERTS + aircraft_list = aircraft_json['states'] + aircraft_report = "" + for aircraft in aircraft_list: + if len(aircraft_report.split("\n")) >= search_limit: + break + # extract values from JSON + try: + callsign = aircraft[1].strip() if aircraft[1] else "N/A" + origin_country = aircraft[2] + velocity = aircraft[9] + true_track = aircraft[10] + vertical_rate = aircraft[11] + sensors = aircraft[12] + geo_altitude = aircraft[13] + squawk = aircraft[14] if len(aircraft) > 14 else "N/A" + except Exception as e: + logger.debug("Location:Error extracting aircraft data from OpenSky Network") + continue + + # format the aircraft data + aircraft_report += f"{callsign} Alt:{int(geo_altitude) if geo_altitude else 'N/A'}m Vel:{int(velocity) if velocity else 'N/A'}m/s Heading:{int(true_track) if true_track else 'N/A'}ยฐ\n" + + # remove last newline + if aircraft_report.endswith("\n"): + aircraft_report = aircraft_report[:-1] + aircraft_report = abbreviate_noaa(aircraft_report) + return aircraft_report if aircraft_report else NO_ALERTS diff --git a/modules/log.py b/modules/log.py index f0ac37a..3ac6932 100644 --- a/modules/log.py +++ b/modules/log.py @@ -1,7 +1,5 @@ import logging from logging.handlers import TimedRotatingFileHandler -import re -from datetime import datetime from modules.settings import * # if LOGGING_LEVEL is not set in settings.py, default to DEBUG if not LOGGING_LEVEL: @@ -38,11 +36,17 @@ class CustomFormatter(logging.Formatter): return formatter.format(record) class plainFormatter(logging.Formatter): - ansi_escape = re.compile(r'\x1b\[([0-9]+)(;[0-9]+)*m') + ansi_codes = [ + '\x1b[38;21m', '\x1b[38;5;231m', '\x1b[38;5;39m', '\x1b[38;5;226m', + '\x1b[38;5;196m', '\x1b[38;5;46m', '\x1b[38;5;129m', '\x1b[31;1m', + '\x1b[37;1m', '\x1b[0m' + ] def format(self, record): message = super().format(record) - return self.ansi_escape.sub('', message) + for code in self.ansi_codes: + message = message.replace(code, '') + return message # Create logger logger = logging.getLogger("MeshBot System Logger") @@ -56,7 +60,6 @@ msgLogger.propagate = False # Define format for logs logFormat = '%(asctime)s | %(levelname)8s | %(message)s' msgLogFormat = '%(asctime)s | %(message)s' -today = datetime.now() # Create stdout handler for logging to the console stdout_handler = logging.StreamHandler() diff --git a/modules/radio.py b/modules/radio.py index 7268ef1..f5e5c27 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -1,13 +1,105 @@ # meshing around with hamlib as a source for info to send to mesh network # detect signal strength and frequency of active channel if appears to be in use send to mesh network # depends on rigctld running externally as a network service +# also can use VOX detection with a microphone and vosk speech to text to send voice messages to mesh network +# requires vosk and sounddevice python modules. will auto download needed. more from https://alphacephei.com/vosk/models and unpack # 2024 Kelly Keeton K7MHI -import socket -import asyncio from modules.log import * +import asyncio + +# verbose debug logging for trap words function +debugVoxTmsg = False + + +if radio_detection_enabled: + # used by hamlib detection + import socket + +if voxDetectionEnabled: + # methods available for trap word processing, these can be called by VOX detection when trap words are detected + from mesh_bot import tell_joke, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass + botMethods = { + "joke": tell_joke, + "weather": handle_wxc, + "moon": handle_moon, + "daylight": handle_sun, + "river": handle_riverFlow, + "tide": handle_tide, + "satellite": handle_satpass} + # module global variables + previousVoxState = False + voxHoldTime = signalHoldTime + + try: + import sounddevice as sd # pip install sounddevice sudo apt install portaudio19-dev + from vosk import Model, KaldiRecognizer # pip install vosk + import json + q = asyncio.Queue(maxsize=32) # queue for audio data + + if useLocalVoxModel: + voxModel = Model(lang=localVoxModelPath) # use built in model for specified language + else: + voxModel = Model(lang=voxLanguage) # use built in model for specified language + + except Exception as e: + print(f"RadioMon: Error importing VOX dependencies: {e}") + print(f"To use VOX detection please install the vosk and sounddevice python modules") + print(f"pip install vosk sounddevice") + print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev") + voxDetectionEnabled = False + logger.error(f"RadioMon: VOX detection disabled due to import error") + +FREQ_NAME_MAP = { + 462562500: "GRMS CH1", + 462587500: "GRMS CH2", + 462612500: "GRMS CH3", + 462637500: "GRMS CH4", + 462662500: "GRMS CH5", + 462687500: "GRMS CH6", + 462712500: "GRMS CH7", + 467562500: "GRMS CH8", + 467587500: "GRMS CH9", + 467612500: "GRMS CH10", + 467637500: "GRMS CH11", + 467662500: "GRMS CH12", + 467687500: "GRMS CH13", + 467712500: "GRMS CH14", + 467737500: "GRMS CH15", + 462550000: "GRMS CH16", + 462575000: "GMRS CH17", + 462600000: "GMRS CH18", + 462625000: "GMRS CH19", + 462675000: "GMRS CH20", + 462670000: "GMRS CH21", + 462725000: "GMRS CH22", + 462725500: "GMRS CH23", + 467575000: "GMRS CH24", + 467600000: "GMRS CH25", + 467625000: "GMRS CH26", + 467650000: "GMRS CH27", + 467675000: "GMRS CH28", + 467700000: "FRS CH1", + 462650000: "FRS CH5", + 462700000: "FRS CH7", + 462737500: "FRS CH16", + 146520000: "2M Simplex Calling", + 446000000: "70cm Simplex Calling", + 156800000: "Marine CH16", + # Add more as needed +} + +def get_freq_common_name(freq): + freq = int(freq) + name = FREQ_NAME_MAP.get(freq) + if name: + return name + else: + # Return MHz if not found + return f"{freq/1000000} Mhz" def get_hamlib(msg="f"): + # get data from rigctld server try: rigControlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) rigControlSocket.settimeout(2) @@ -29,110 +121,47 @@ def get_hamlib(msg="f"): except Exception as e: logger.error(f"RadioMon: Error fetching data from rigctld: {e}") return ERROR_FETCHING_DATA - -def get_freq_common_name(freq): - freq = int(freq) - if freq == 462562500: - return "GRMS CH1" - elif freq == 462587500: - return "GRMS CH2" - elif freq == 462612500: - return "GRMS CH3" - elif freq == 462637500: - return "GRMS CH4" - elif freq == 462662500: - return "GRMS CH5" - elif freq == 462687500: - return "GRMS CH6" - elif freq == 462712500: - return "GRMS CH7" - elif freq == 467562500: - return "GRMS CH8" - elif freq == 467587500: - return "GRMS CH9" - elif freq == 467612500: - return "GRMS CH10" - elif freq == 467637500: - return "GRMS CH11" - elif freq == 467662500: - return "GRMS CH12" - elif freq == 467687500: - return "GRMS CH13" - elif freq == 467712500: - return "GRMS CH14" - elif freq == 467737500: - return "GRMS CH15" - elif freq == 462550000: - return "GRMS CH16" - elif freq == 462575000: - return "GMRS CH17" - elif freq == 462600000: - return "GMRS CH18" - elif freq == 462625000: - return "GMRS CH19" - elif freq == 462675000: - return "GMRS CH20" - elif freq == 462670000: - return "GMRS CH21" - elif freq == 462725000: - return "GMRS CH22" - elif freq == 462725500: - return "GMRS CH23" - elif freq == 467575000: - return "GMRS CH24" - elif freq == 467600000: - return "GMRS CH25" - elif freq == 467625000: - return "GMRS CH26" - elif freq == 467650000: - return "GMRS CH27" - elif freq == 467675000: - return "GMRS CH28" - elif freq == 467700000: - return "FRS CH1" - elif freq == 462575000: - return "FRS CH2" - elif freq == 462600000: - return "FRS CH3" - elif freq == 462650000: - return "FRS CH5" - elif freq == 462675000: - return "FRS CH6" - elif freq == 462700000: - return "FRS CH7" - elif freq == 462725000: - return "FRS CH8" - elif freq == 462562500: - return "FRS CH9" - elif freq == 462587500: - return "FRS CH10" - elif freq == 462612500: - return "FRS CH11" - elif freq == 462637500: - return "FRS CH12" - elif freq == 462662500: - return "FRS CH13" - elif freq == 462687500: - return "FRS CH14" - elif freq == 462712500: - return "FRS CH15" - elif freq == 462737500: - return "FRS CH16" - elif freq == 146520000: - return "2M Simplex Calling" - elif freq == 446000000: - return "70cm Simplex Calling" - elif freq == 156800000: - return "Marine CH16" - else: - #return Mhz - freq = freq/1000000 - return f"{freq} Mhz" def get_sig_strength(): strength = get_hamlib('l STRENGTH') return strength +def checkVoxTrapWords(text): + try: + if not voxOnTrapList: + logger.debug(f"RadioMon: VOX detected: {text}") + return text + if text: + traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList + text_lower = text.lower() + for trap in traps: + trap_clean = trap.strip() + trap_lower = trap_clean.lower() + idx = text_lower.find(trap_lower) + if debugVoxTmsg: + logger.debug(f"RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})") + if idx != -1: + new_text = text[idx + len(trap_clean):].strip() + if debugVoxTmsg: + logger.debug(f"RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')") + new_words = new_text.split() + if voxEnableCmd: + for word in new_words: + if word in botMethods: + logger.info(f"RadioMon: VOX action '{word}' with '{new_text}'") + if word == "joke": + return botMethods[word](vox=True) + else: + return botMethods[word](None, None, None, vox=True) + logger.debug(f"RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'") + return new_text + if debugVoxTmsg: + logger.debug(f"RadioMon: VOX no trap word found in: '{text}'") + return None + except Exception as e: + logger.debug(f"RadioMon: Error in checkVoxTrapWords: {e}") + return None + async def signalWatcher(): global previousStrength global signalCycle @@ -157,4 +186,61 @@ async def signalWatcher(): signalCycle = 0 previousStrength = -40 -# end of file \ No newline at end of file +async def make_vox_callback(loop, q): + def vox_callback(indata, frames, time, status): + if status: + logger.warning(f"RadioMon: VOX input status: {status}") + try: + loop.call_soon_threadsafe(q.put_nowait, bytes(indata)) + except asyncio.QueueFull: + # Drop the oldest item and add the new one + try: + q.get_nowait() # Remove oldest + except asyncio.QueueEmpty: + pass + try: + loop.call_soon_threadsafe(q.put_nowait, bytes(indata)) + except asyncio.QueueFull: + # If still full, just drop this frame + logger.debug("RadioMon: VOX queue full, dropping audio frame") + except RuntimeError: + # Loop may be closed + pass + return vox_callback + +async def voxMonitor(): + global previousVoxState, voxMsgQueue + try: + model = voxModel + device_info = sd.query_devices(voxInputDevice, 'input') + samplerate = 16000 + logger.debug(f"RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}") + rec = KaldiRecognizer(model, samplerate) + loop = asyncio.get_running_loop() + callback = await make_vox_callback(loop, q) + with sd.RawInputStream( + device=voxInputDevice, + samplerate=samplerate, + blocksize=4000, + dtype='int16', + channels=1, + callback=callback + ): + while True: + data = await q.get() + if rec.AcceptWaveform(data): + result = rec.Result() + text = json.loads(result).get("text", "") + # process text + if text and text != 'huh': + result = checkVoxTrapWords(text) + if result: + # If result is a function return, handle it (send to mesh, log, etc.) + # If it's just text, handle as a normal message + voxMsgQueue.append(result) + + await asyncio.sleep(0.1) + except Exception as e: + logger.error(f"RadioMon: Error in VOX monitor: {e}") + +# end of file diff --git a/modules/rss.py b/modules/rss.py new file mode 100644 index 0000000..a2a3cf3 --- /dev/null +++ b/modules/rss.py @@ -0,0 +1,95 @@ +# rss feed module for meshing-around 2025 +from modules.log import * +import urllib.request +import xml.etree.ElementTree as ET +import html +from html.parser import HTMLParser + +class MLStripper(HTMLParser): + def __init__(self): + super().__init__() + self.reset() + self.fed = [] + def handle_data(self, d): + self.fed.append(d) + def get_data(self): + return ''.join(self.fed) + +def strip_tags(html_text): + s = MLStripper() + s.feed(html_text) + return s.get_data() + +RSS_FEED_URLS = rssFeedURL +RSS_FEED_NAMES = rssFeedNames +RSS_RETURN_COUNT = rssMaxItems +RSS_TRIM_LENGTH = rssTruncate + +def get_rss_feed(msg): + # Determine which feed to use + feed_name = "" + msg_lower = msg.lower() if msg else "" + if msg_lower and any(name.lower() in msg_lower for name in RSS_FEED_NAMES): + for name in RSS_FEED_NAMES: + if name.lower() in msg_lower: + feed_name = name + break + else: + logger.debug(f"RSS: No feed name found in message '{msg}'. Using default feed.") + feed_name = RSS_FEED_NAMES[0] if RSS_FEED_NAMES else "default" + + try: + idx = RSS_FEED_NAMES.index(feed_name) + feed_url = RSS_FEED_URLS[idx] + except (ValueError, IndexError): + logger.warning(f"RSS: Feed '{feed_name}' not found in RSS_FEED_URLS ({RSS_FEED_URLS}).") + return f"Feed '{feed_name}' not found." + + if "?" in msg_lower: + return f"Fetches the latest {RSS_RETURN_COUNT} entries RSS feeds. Available feeds are: {', '.join(RSS_FEED_NAMES)}. To fetch a specific feed, include its name in your request." + + try: + logger.debug(f"Fetching RSS feed from {feed_url} from message '{msg}'") + agent = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'} + request = urllib.request.Request(feed_url, headers=agent) + with urllib.request.urlopen(request, timeout=urlTimeoutSeconds) as response: + xml_data = response.read() + root = ET.fromstring(xml_data) + # Try both namespaced and non-namespaced item tags + items = root.findall('.//item') + ns = None + if not items: + # Try to find the namespace dynamically + for elem in root.iter(): + if elem.tag.endswith('item'): + ns_uri = elem.tag.split('}')[0].strip('{') + items = root.findall(f'.//{{{ns_uri}}}item') + ns = ns_uri + break + items = items[:RSS_RETURN_COUNT] + if not items: + return "No RSS feed entries found." + formatted_entries = [] + for item in items: + if ns: + title = item.findtext(f'{{{ns}}}title', default='No title') + link = item.findtext(f'{{{ns}}}link', default=None) + description = item.findtext(f'{{{ns}}}description', default='No description') + pub_date = item.findtext(f'{{{ns}}}pubDate', default='No date') + else: + title = item.findtext('title', default='No title') + link = item.findtext('link', default=None) + description = item.findtext('description', default='No description') + pub_date = item.findtext('pubDate', default='No date') + + # Unescape HTML entities and strip tags + description = html.unescape(description) + description = strip_tags(description) + if len(description) > RSS_TRIM_LENGTH: + description = description[:RSS_TRIM_LENGTH - 3] + "..." + + formatted_entries.append(f"{title}\n{description}\n") + return "\n".join(formatted_entries) + except Exception as e: + logger.error(f"Error fetching RSS feed from {feed_url}: {e}") + return ERROR_FETCHING_DATA diff --git a/modules/scheduler.py b/modules/scheduler.py new file mode 100644 index 0000000..29c079a --- /dev/null +++ b/modules/scheduler.py @@ -0,0 +1,118 @@ +# modules/scheduler.py 2025 meshing-around +# Scheduler setup for Mesh Bot +import asyncio +import schedule +from modules.log import logger +from modules.system import send_message + +async def setup_scheduler( + schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface, + schedulerValue, schedulerTime, schedulerInterval, logger, BroadcastScheduler): + + # methods available for custom scheduler messages + from mesh_bot import tell_joke, welcome_message, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass + schedulerValue = schedulerValue.lower().strip() + schedulerTime = schedulerTime.strip() + schedulerInterval = schedulerInterval.strip() + schedulerChannel = int(schedulerChannel) + schedulerInterface = int(schedulerInterface) + # Setup the scheduler based on configuration + try: + if schedulerMotd: + scheduler_message = MOTD + else: + scheduler_message = schedulerMessage + + # Basic Scheduler Options + basicOptions = ['day', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', 'hour', 'min'] + if any(option.lower() in schedulerValue.lower() for option in basicOptions): + # Basic scheduler job to run the schedule see examples below for custom schedules + if schedulerValue.lower() == 'day': + if schedulerTime != '': + schedule.every().day.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + else: + schedule.every(int(schedulerInterval)).days.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'mon' in schedulerValue.lower() and schedulerTime != '': + schedule.every().monday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'tue' in schedulerValue.lower() and schedulerTime != '': + schedule.every().tuesday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'wed' in schedulerValue.lower() and schedulerTime != '': + schedule.every().wednesday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'thu' in schedulerValue.lower() and schedulerTime != '': + schedule.every().thursday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'fri' in schedulerValue.lower() and schedulerTime != '': + schedule.every().friday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'sat' in schedulerValue.lower() and schedulerTime != '': + schedule.every().saturday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'sun' in schedulerValue.lower() and schedulerTime != '': + schedule.every().sunday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'hour' in schedulerValue.lower(): + schedule.every(int(schedulerInterval)).hours.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + elif 'min' in schedulerValue.lower(): + schedule.every(int(schedulerInterval)).minutes.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)) + logger.debug(f"System: Starting the basic scheduler to send '{scheduler_message}' on schedule '{schedulerValue}' every {schedulerInterval} interval at time '{schedulerTime}' on Device:{schedulerInterface} Channel:{schedulerChannel}") + elif 'joke' in schedulerValue.lower(): + # Schedule to send a joke every specified interval + schedule.every(int(schedulerInterval)).minutes.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)) + logger.debug(f"System: Starting the joke scheduler to send a joke every {schedulerInterval} minutes on Device:{schedulerInterface} Channel:{schedulerChannel}") + elif 'weather' in schedulerValue.lower(): + # Schedule to send weather updates every specified interval + schedule.every(int(schedulerInterval)).hours.do(lambda: send_message(handle_wxc(0, schedulerInterface, 'wx'), schedulerChannel, 0, schedulerInterface)) + logger.debug(f"System: Starting the weather scheduler to send weather updates every {schedulerInterval} hours on Device:{schedulerInterface} Channel:{schedulerChannel}") + elif 'custom' in schedulerValue.lower(): + # Custom scheduler job to run the schedule see examples below + + # custom scheduler job to run the schedule see examples below + logger.debug(f"System: Starting the custom scheduler default to send reminder every Monday at noon on Device:{schedulerInterface} Channel:{schedulerChannel}") + schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Enabled Reminder")) + + # send a joke every 15 minutes + #schedule.every(15).minutes.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)) + + # Place your custom schedule code below this line, helps with merges + + # Place your custom schedule code above this line + + # Start the Broadcast Scheduler + await BroadcastScheduler() + except Exception as e: + logger.error(f"System: Scheduler Error {e}") + +# Enhanced Examples of using the scheduler, Times here are in 24hr format +# https://schedule.readthedocs.io/en/stable/ + +# Good Morning Every day at 09:00 using send_message function to channel 2 on device 1 +#schedule.every().day.at("09:00").do(lambda: send_message("Good Morning", 2, 0, 1)) + +# Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1 +#schedule.every().day.at("08:00").do(lambda: send_message(handle_wxc(0, 1, 'wx'), 2, 0, 1)) + +# Send Weather Channel Notice Wed. Noon on channel 2, device 1 +#schedule.every().wednesday.at("12:00").do(lambda: send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", 2, 0, 1)) + +# Send config URL for Medium Fast Network Use every other day at 10:00 to default channel 2 on device 1 +#schedule.every(2).days.at("10:00").do(lambda: send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", 2, 0, 1)) + +# Send a Net Starting Now Message Every Wednesday at 19:00 using send_message function to channel 2 on device 1 +#schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now", 2, 0, 1)) + +# Send a Welcome Notice for group on the 15th and 25th of the month at 12:00 using send_message function to channel 2 on device 1 +#schedule.every().day.at("12:00").do(lambda: send_message("Welcome to the group", 2, 0, 1)).day(15, 25) + +# Send a Welcome Notice for group on the 15th and 25th of the month at 12:00 +#schedule.every().day.at("12:00").do(lambda: send_message("Welcome to the group", schedulerChannel, 0, schedulerInterface)).day(15, 25) + +# Send a joke every 6 hours +#schedule.every(6).hours.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)) + +# Send a joke every 2 minutes +#schedule.every(2).minutes.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)) + +# Send the Welcome Message every other day at 08:00 +#schedule.every(2).days.at("08:00").do(lambda: send_message(welcome_message, schedulerChannel, 0, schedulerInterface)) + +# Send the MOTD every day at 13:00 +#schedule.every().day.at("13:00").do(lambda: send_message(MOTD, schedulerChannel, 0, schedulerInterface)) + +# Send bbslink looking for peers every other day at 10:00 +#schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface)) \ No newline at end of file diff --git a/modules/settings.py b/modules/settings.py index 32509aa..4588986 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -28,6 +28,23 @@ wiki_return_limit = 3 # limit the number of sentences returned off the first par GAMEDELAY = 28800 # 8 hours in seconds for game mode holdoff cmdHistory = [] # list to hold the last commands seenNodes = [] # list to hold the last seen nodes +cmdHistory = [] # list to hold the command history for lheard and history commands +msg_history = [] # list to hold the message history for the messages command +max_bytes = 200 # Meshtastic has ~237 byte limit, use conservative 200 bytes for message content +voxMsgQueue = [] # queue for VOX detected messages +# Game trackers +surveyTracker = [] # Survey game tracker +tictactoeTracker = [] # TicTacToe game tracker +hamtestTracker = [] # Ham radio test tracker +hangmanTracker = [] # Hangman game tracker +golfTracker = [] # GolfSim game tracker +mastermindTracker = [] # Mastermind game tracker +vpTracker = [] # Video Poker game tracker +jackTracker = [] # Blackjack game tracker +lemonadeTracker = [] # Lemonade Stand game tracker +dwPlayerTracker = [] # DopeWars player tracker +jackTracker = [] # Jack game tracker +mindTracker = [] # Mastermind (mmind) game tracker # Read the config file, if it does not exist, create basic config file config = configparser.ConfigParser() @@ -37,6 +54,10 @@ try: config.read(config_file, encoding='utf-8') except Exception as e: print(f"System: Error reading config file: {e}") + # exit if we can't read the config file + print(f"System: Check the config.ini against config.template file for missing sections or values.") + print(f"System: Exiting...") + exit(1) if config.sections() == []: print(f"System: Error reading config file: {config_file} is empty or does not exist.") @@ -197,14 +218,16 @@ try: ignoreChannels = config['general'].get('ignoreChannels', '').split(',') # ignore these channels ignoreDefaultChannel = config['general'].getboolean('ignoreDefaultChannel', False) cmdBang = config['general'].getboolean('cmdBang', False) # default off + explicitCmd = config['general'].getboolean('explicitCmd', True) # default on zuluTime = config['general'].getboolean('zuluTime', False) # aka 24 hour time log_messages_to_file = config['general'].getboolean('LogMessagesToFile', False) # default off log_backup_count = config['general'].getint('LogBackupCount', 32) # default 32 days syslog_to_file = config['general'].getboolean('SyslogToFile', True) # default on LOGGING_LEVEL = config['general'].get('sysloglevel', 'DEBUG') # default DEBUG - urlTimeoutSeconds = config['general'].getint('urlTimeout', 10) # default 10 seconds + urlTimeoutSeconds = config['general'].getint('urlTimeout', 15) # default 15 seconds for URL fetch timeout store_forward_enabled = config['general'].getboolean('StoreForward', True) storeFlimit = config['general'].getint('StoreLimit', 3) # default 3 messages for S&F + reverseSF = config['general'].getboolean('reverseSF', False) # default False, send oldest first welcome_message = config['general'].get('welcome_message', WELCOME_MSG) welcome_message = (f"{welcome_message}").replace('\\n', '\n') # allow for newlines in the welcome message motd_enabled = config['general'].getboolean('motdEnabled', True) @@ -218,11 +241,24 @@ try: bee_enabled = config['general'].getboolean('bee', False) # ๐Ÿ off by default undocumented solar_conditions_enabled = config['general'].getboolean('spaceWeather', True) wikipedia_enabled = config['general'].getboolean('wikipedia', False) + use_kiwix_server = config['general'].getboolean('useKiwixServer', False) + kiwix_url = config['general'].get('kiwixURL', 'http://127.0.0.1:8080') + kiwix_library_name = config['general'].get('kiwixLibraryName', 'wikipedia_en_100_nopic_2024-06') llm_enabled = config['general'].getboolean('ollama', False) # https://ollama.com - llmModel = config['general'].get('ollamaModel', 'gemma2:2b') # default gemma2:2b ollamaHostName = config['general'].get('ollamaHostName', 'http://localhost:11434') # default localhost - llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) + llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m + rawLLMQuery = config['general'].getboolean('rawLLMQuery', True) #default True + llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) # default True dont_retry_disconnect = config['general'].getboolean('dont_retry_disconnect', False) # default False, retry on disconnect + favoriteNodeList = config['general'].get('favoriteNodeList', '').split(',') + enableEcho = config['general'].getboolean('enableEcho', False) # default False + echoChannel = config['general'].getint('echoChannel', '9') # default 9, empty string to ignore + rssEnable = config['general'].getboolean('rssEnable', True) # default True + rssFeedURL = config['general'].get('rssFeedURL', 'http://www.hackaday.com/rss.xml,https://www.arrl.org/rss/arrl.rss').split(',') + rssMaxItems = config['general'].getint('rssMaxItems', 3) # default 3 items + rssTruncate = config['general'].getint('rssTruncate', 100) # default 100 characters + rssFeedNames = config['general'].get('rssFeedNames', 'default,arrl').split(',') + # emergency response emergency_responder_enabled = config['emergencyHandler'].getboolean('enabled', False) emergency_responder_alert_channel = config['emergencyHandler'].getint('alert_channel', 2) # default 2 @@ -232,28 +268,39 @@ try: # sentry sentry_enabled = config['sentry'].getboolean('SentryEnabled', False) # default False secure_channel = config['sentry'].getint('SentryChannel', 2) # default 2 + secure_interface = config['sentry'].getint('SentryInterface', 1) # default 1 sentry_holdoff = config['sentry'].getint('SentryHoldoff', 9) # default 9 sentryIgnoreList = config['sentry'].get('sentryIgnoreList', '').split(',') + sentryWatchList = config['sentry'].get('sentryWatchList', '').split(',') sentry_radius = config['sentry'].getint('SentryRadius', 100) # default 100 meters email_sentry_alerts = config['sentry'].getboolean('emailSentryAlerts', False) # default False highfly_enabled = config['sentry'].getboolean('highFlyingAlert', True) # default True highfly_altitude = config['sentry'].getint('highFlyingAlertAltitude', 2000) # default 2000 meters highfly_channel = config['sentry'].getint('highFlyingAlertChannel', 2) # default 2 + highfly_interface = config['sentry'].getint('highFlyingAlertInterface', 1) # default 1 highfly_ignoreList = config['sentry'].get('highFlyingIgnoreList', '').split(',') # default empty + highfly_check_openskynetwork = config['sentry'].getboolean('highflyOpenskynetwork', True) # default True check with OpenSkyNetwork if highfly detected + detctionSensorAlert = config['sentry'].getboolean('detectionSensorAlert', False) # default False + reqLocationEnabled = config['sentry'].getboolean('reqLocationEnabled', False) # default False + cmdShellSentryAlerts = config['sentry'].getboolean('cmdShellSentryAlerts', False) # default False + sentryAlertNear = config['sentry'].get('sentryAlertNear', 'sentry_alert_near.sh') # default sentry_alert_near.sh + sentryAlertFar = config['sentry'].get('sentryAlertFar', 'sentry_alert_far.sh') # default sentry_alert_far.sh # location location_enabled = config['location'].getboolean('enabled', True) latitudeValue = config['location'].getfloat('lat', 48.50) longitudeValue = config['location'].getfloat('lon', -123.0) + fuzz_config_location = config['location'].getboolean('fuzzConfigLocation', True) # default True + fuzzItAll = config['location'].getboolean('fuzzAllLocations', False) # default False, only fuzz config location use_meteo_wxApi = config['location'].getboolean('UseMeteoWxAPI', False) # default False use NOAA use_metric = config['location'].getboolean('useMetric', False) # default Imperial units repeater_lookup = config['location'].get('repeaterLookup', 'rbook') # default repeater lookup source n2yoAPIKey = config['location'].get('n2yoAPIKey', '') # default empty satListConfig = config['location'].get('satList', '25544').split(',') # default 25544 ISS - riverListDefault = config['location'].get('riverList', '').split(',') # default 12061500 Skagit River - pzzEnabled = config['location'].getboolean('pzzEnabled', False) # default False - pzzZoneID = config['location'].getint('pzzZoneID', 100) # default 100, PZZ132 for Seattle area - pzzForecastDays = config['location'].getint('pzzForecastDays', 3) # default 3 days + riverListDefault = config['location'].get('riverList', '').split(',') # default None + coastalEnabled = config['location'].getboolean('coastalEnabled', False) # default False + myCoastalZone = config['location'].get('myCoastalZone', None) # default None + coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days # location alerts emergencyAlertBrodcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # default False @@ -285,6 +332,7 @@ try: bbs_admin_list = config['bbs'].get('bbs_admin_list', '').split(',') bbs_link_enabled = config['bbs'].getboolean('bbslink_enabled', False) bbs_link_whitelist = config['bbs'].get('bbslink_whitelist', '').split(',') + bbsAPI_enabled = config['bbs'].getboolean('bbsAPI_enabled', False) # checklist checklist_enabled = config['checklist'].getboolean('enabled', False) @@ -326,27 +374,42 @@ try: schedulerInterval = config['scheduler'].get('interval', '') # default empty schedulerTime = config['scheduler'].get('time', '') # default empty schedulerValue = config['scheduler'].get('value', '') # default empty + schedulerMotd = config['scheduler'].getboolean('schedulerMotd', False) # default False # radio monitoring radio_detection_enabled = config['radioMon'].getboolean('enabled', False) rigControlServerAddress = config['radioMon'].get('rigControlServerAddress', 'localhost:4532') # default localhost:4532 sigWatchBroadcastCh = config['radioMon'].get('sigWatchBroadcastCh', '2').split(',') # default Channel 2 + sigWatchBroadcastInterface = config['radioMon'].getint('sigWatchBroadcastInterface', 1) # default interface 1 signalDetectionThreshold = config['radioMon'].getint('signalDetectionThreshold', -10) # default -10 dBm signalHoldTime = config['radioMon'].getint('signalHoldTime', 10) # default 10 seconds signalCooldown = config['radioMon'].getint('signalCooldown', 5) # default 1 second signalCycleLimit = config['radioMon'].getint('signalCycleLimit', 5) # default 5 cycles, used with SIGNAL_COOLDOWN + voxDetectionEnabled = config['radioMon'].getboolean('voxDetectionEnabled', False) # default VOX detection disabled + voxDescription = config['radioMon'].get('voxDescription', 'VOX') # default VOX detected audio message + useLocalVoxModel = config['radioMon'].getboolean('useLocalVoxModel', False) # default False + localVoxModelPath = config['radioMon'].get('localVoxModelPath', 'no') # default models/vox.tflite + voxLanguage = config['radioMon'].get('voxLanguage', 'en-US') # default en-US + voxInputDevice = config['radioMon'].get('voxInputDevice', 'default') # default default + voxOnTrapList = config['radioMon'].getboolean('voxOnTrapList', False) # default False + voxTrapList = config['radioMon'].get('voxTrapList', 'chirpy').split(',') # default chirpy + voxEnableCmd = config['radioMon'].getboolean('voxEnableCmd', True) # default True # file monitor file_monitor_enabled = config['fileMon'].getboolean('filemon_enabled', False) file_monitor_file_path = config['fileMon'].get('file_path', 'alert.txt') # default alert.txt file_monitor_broadcastCh = config['fileMon'].get('broadcastCh', '2').split(',') # default Channel 2 read_news_enabled = config['fileMon'].getboolean('enable_read_news', False) # default disabled - news_file_path = config['fileMon'].get('news_file_path', 'news.txt') # default news.txt + news_file_path = config['fileMon'].get('news_file_path', '../data/news.txt') # default ../data/news.txt news_random_line_only = config['fileMon'].getboolean('news_random_line', False) # default False enable_runShellCmd = config['fileMon'].getboolean('enable_runShellCmd', False) # default False + allowXcmd = config['fileMon'].getboolean('allowXcmd', False) # default False + xCmd2factorEnabled = config['fileMon'].getboolean('2factor_enabled', True) # default True + xCmd2factor_timeout = config['fileMon'].getint('2factor_timeout', 100) # default 100 seconds # games - game_hop_limit = config['messagingSettings'].getint('game_hop_limit', 5) # default 3 hops + game_hop_limit = config['games'].getint('game_hop_limit', 5) # default 5 hops + disable_emojis_in_games = config['games'].getboolean('disable_emojis', False) # default False dopewars_enabled = config['games'].getboolean('dopeWars', True) lemonade_enabled = config['games'].getboolean('lemonade', True) blackjack_enabled = config['games'].getboolean('blackjack', True) @@ -355,16 +418,27 @@ try: golfSim_enabled = config['games'].getboolean('golfSim', True) hangman_enabled = config['games'].getboolean('hangman', True) hamtest_enabled = config['games'].getboolean('hamtest', True) + tictactoe_enabled = config['games'].getboolean('tictactoe', True) + quiz_enabled = config['games'].getboolean('quiz', False) + survey_enabled = config['games'].getboolean('survey', False) + default_survey = config['games'].get('defaultSurvey', 'example') # default example + surveyRecordID = config['games'].getboolean('surveyRecordID', True) + surveyRecordLocation = config['games'].getboolean('surveyRecordLocation', True) # messaging settings responseDelay = config['messagingSettings'].getfloat('responseDelay', 0.7) # default 0.7 splitDelay = config['messagingSettings'].getfloat('splitDelay', 0) # default 0 - MESSAGE_CHUNK_SIZE = config['messagingSettings'].getint('MESSAGE_CHUNK_SIZE', 160) # default 160 + MESSAGE_CHUNK_SIZE = config['messagingSettings'].getint('MESSAGE_CHUNK_SIZE', 160) # default 160 chars wantAck = config['messagingSettings'].getboolean('wantAck', False) # default False - maxBuffer = config['messagingSettings'].getint('maxBuffer', 220) # default 220 + maxBuffer = config['messagingSettings'].getint('maxBuffer', 200) # default 200 bytes enableHopLogs = config['messagingSettings'].getboolean('enableHopLogs', False) # default False - -except KeyError as e: + debugMetadata = config['messagingSettings'].getboolean('debugMetadata', False) # default False + metadataFilter = config['messagingSettings'].get('metadataFilter', '').split(',') # default empty + DEBUGpacket = config['messagingSettings'].getboolean('DEBUGpacket', False) # default False + noisyNodeLogging = config['messagingSettings'].getboolean('noisyNodeLogging', False) # default False + logMetaStats = config['messagingSettings'].getboolean('logMetaStats', True) # default True + noisyTelemetryLimit = config['messagingSettings'].getint('noisyTelemetryLimit', 5) # default 5 packets +except Exception as e: print(f"System: Error reading config file: {e}") print(f"System: Check the config.ini against config.template file for missing sections or values.") print(f"System: Exiting...") diff --git a/modules/smtp.py b/modules/smtp.py index c4e3ab6..b469eb8 100644 --- a/modules/smtp.py +++ b/modules/smtp.py @@ -152,6 +152,12 @@ def store_sms(nodeID, sms): global sms_db try: logger.debug("System: Setting SMS for " + str(nodeID)) + # if the nodeID has over 5 sms addresses warn and return + for item in sms_db: + if item['nodeID'] == nodeID: + if len(item['sms']) >= 5: + logger.warning("System: ๐Ÿ“ตSMS limit reached for " + str(nodeID)) + return False # if not in db, add it if nodeID not in sms_db: sms_db.append({'nodeID': nodeID, 'sms': sms}) diff --git a/modules/space.py b/modules/space.py index 868728e..aa72b33 100644 --- a/modules/space.py +++ b/modules/space.py @@ -6,20 +6,25 @@ import requests # pip install requests import xml.dom.minidom from datetime import datetime import ephem # pip install pyephem -from datetime import timedelta +from datetime import timezone from modules.log import * +import math -trap_list_solarconditions = ("sun", "moon", "solar", "hfcond", "satpass") +trap_list_solarconditions = ("sun", "moon", "solar", "hfcond", "satpass", "howtall") def hf_band_conditions(): # ham radio HF band conditions hf_cond = "" + signalnoise = "" band_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds) if(band_cond.ok): solarxml = xml.dom.minidom.parseString(band_cond.text) for i in solarxml.getElementsByTagName("band"): hf_cond += i.getAttribute("time")[0]+i.getAttribute("name") +"="+str(i.childNodes[0].data)+"\n" hf_cond = hf_cond[:-1] # remove the last newline + for i in solarxml.getElementsByTagName("solardata"): + signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data + hf_cond += "\nQRN:" + signalnoise else: logger.error("Solar: Error fetching HF band conditions") hf_cond = ERROR_FETCHING_DATA @@ -63,7 +68,7 @@ def drap_xray_conditions(): def get_sun(lat=0, lon=0): # get sunrise and sunset times using callers location or default obs = ephem.Observer() - obs.date = datetime.now() + obs.date = datetime.now(timezone.utc) sun = ephem.Sun() if lat != 0 and lon != 0: obs.lat = str(lat) @@ -74,9 +79,17 @@ def get_sun(lat=0, lon=0): sun.compute(obs) sun_table = {} + + # get the sun azimuth and altitude sun_table['azimuth'] = sun.az sun_table['altitude'] = sun.alt + # sun is up include altitude + if sun_table['altitude'] > 0: + sun_table['altitude'] = sun.alt + else: + sun_table['altitude'] = 0 + # get the next rise and set times local_sunrise = ephem.localtime(obs.next_rising(sun)) local_sunset = ephem.localtime(obs.next_setting(sun)) @@ -86,19 +99,25 @@ def get_sun(lat=0, lon=0): else: sun_table['rise_time'] = local_sunrise.strftime('%a %d %I:%M%p') sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p') - # if sunset is before sunrise, then it's tomorrow + + # if sunset is before sunrise, then data will be for tomorrow format sunset first and sunrise second if local_sunset < local_sunrise: - local_sunset = ephem.localtime(obs.next_setting(sun)) + timedelta(1) - if zuluTime: - sun_table['set_time'] = local_sunset.strftime('%a %d %H:%M') - else: - sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p') - sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time'] + sun_data = "SunSet: " + sun_table['set_time'] + "\nRise: " + sun_table['rise_time'] + else: + sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time'] + + sun_data += "\nDaylight: " + str((local_sunset - local_sunrise).seconds // 3600) + "h " + str(((local_sunset - local_sunrise).seconds // 60) % 60) + "m" + + if sun_table['altitude'] > 0: + sun_data += "\nRemaining: " + str((local_sunset - datetime.now()).seconds // 3600) + "h " + str(((local_sunset - datetime.now()).seconds // 60) % 60) + "m" + + sun_data += "\nAzimuth: " + str('{0:.2f}'.format(sun_table['azimuth'] * 180 / ephem.pi)) + "ยฐ" + if sun_table['altitude'] > 0: + sun_data += "\nAltitude: " + str('{0:.2f}'.format(sun_table['altitude'] * 180 / ephem.pi)) + "ยฐ" return sun_data def get_moon(lat=0, lon=0): # get moon phase and rise/set times using callers location or default - # the phase calculation mght not be accurate (followup later) obs = ephem.Observer() moon = ephem.Moon() if lat != 0 and lon != 0: @@ -108,10 +127,28 @@ def get_moon(lat=0, lon=0): obs.lat = str(latitudeValue) obs.lon = str(longitudeValue) - obs.date = datetime.now() + obs.date = datetime.now(timezone.utc) moon.compute(obs) moon_table = {} - moon_phase = ['NewMoon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'FullMoon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][round(moon.phase / (2 * ephem.pi) * 8) % 8] + illum = moon.phase # 0 = new, 50 = first/last quarter, 100 = full + + if illum < 1.0: + moon_phase = 'New Moon๐ŸŒ‘' + elif illum < 49: + moon_phase = 'Waxing Crescent ๐ŸŒ’' + elif 49 <= illum < 51: + moon_phase = 'First Quarter ๐ŸŒ“' + elif illum < 99: + moon_phase = 'Waxing Gibbous ๐ŸŒ”' + elif illum >= 99: + moon_phase = 'Full Moon๐ŸŒ•' + elif illum > 51: + moon_phase = 'Waning Gibbous ๐ŸŒ–' + elif 51 >= illum > 49: + moon_phase = 'Last Quarter ๐ŸŒ—' + else: + moon_phase = 'Waning Crescent ๐ŸŒ˜' + moon_table['phase'] = moon_phase moon_table['illumination'] = moon.phase moon_table['azimuth'] = moon.az @@ -135,9 +172,14 @@ def get_moon(lat=0, lon=0): moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %I:%M%p') moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %I:%M%p') - moon_data = "MoonRise:" + moon_table['rise_time'] + "\nSet:" + moon_table['set_time'] + \ - "\nPhase:" + moon_table['phase'] + " @:" + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \ - + "\nFullMoon:" + moon_table['next_full_moon'] + "\nNewMoon:" + moon_table['next_new_moon'] + moon_data = "MoonRise: " + moon_table['rise_time'] + "\nSet: " + moon_table['set_time'] + \ + "\nPhase: " + moon_table['phase'] + " @: " + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \ + + "\nFullMoon: " + moon_table['next_full_moon'] + "\nNewMoon: " + moon_table['next_new_moon'] + + # if moon is in the sky, add azimuth and altitude + if moon_table['altitude'] > 0: + moon_data += "\nAz: " + str('{0:.2f}'.format(moon_table['azimuth'] * 180 / ephem.pi)) + "ยฐ" + \ + "\nAlt: " + str('{0:.2f}'.format(moon_table['altitude'] * 180 / ephem.pi)) + "ยฐ" return moon_data @@ -169,7 +211,7 @@ def getNextSatellitePass(satellite, lat=0, lon=0): pass_startAzCompass = pass_json['passes'][0]['startAzCompass'] pass_set_time = datetime.fromtimestamp(pass_time + pass_duration).strftime('%a %d %I:%M%p') pass__endAzCompass = pass_json['passes'][0]['endAzCompass'] - pass_data = f"{satname} @{pass_rise_time} Az:{pass_startAzCompass} for{getPrettyTime(pass_duration)}, MaxEl:{pass_maxEl}ยฐ Set@{pass_set_time} Az:{pass__endAzCompass}" + pass_data = f"{satname} @{pass_rise_time} Az: {pass_startAzCompass} for{getPrettyTime(pass_duration)}, MaxEl: {pass_maxEl}ยฐ Set @{pass_set_time} Az: {pass__endAzCompass}" elif pass_json['info']['passescount'] == 0: satname = pass_json['info']['satname'] pass_data = f"{satname} has no upcoming passes" @@ -178,5 +220,33 @@ def getNextSatellitePass(satellite, lat=0, lon=0): pass_data = ERROR_FETCHING_DATA except Exception as e: logger.warning(f"System: User supplied value {satellite} unknown or invalid") - pass_data = "Provide NORAD# example use:๐Ÿ›ฐ๏ธsatpass 25544,33591" + pass_data = "Provide NORAD# example use: ๐Ÿ›ฐ๏ธsatpass 25544,33591" return pass_data + +def measureHeight(lat=0, lon=0, shadow=0): + # measure height of a given location using sun angle and shadow length + if lat == 0 and lon == 0: + return NO_DATA_NOGPS + if shadow == 0: + return NO_ALERTS + obs = ephem.Observer() + obs.lat = str(lat) + obs.lon = str(lon) + obs.date = datetime.now(timezone.utc) + sun = ephem.Sun() + sun.compute(obs) + sun_altitude = sun.alt * 180 / ephem.pi + if sun_altitude <= 0: + return "โ˜€๏ธSun is below horizon, I dont belive your shadow measurement" + try: + if use_metric: + height = float(shadow) * math.tan(sun.alt) + return f"๐Ÿ“Object Height: {height:.2f} m (Shadow: {shadow} m, ๐Ÿ“Sun Alt: {sun_altitude:.2f}ยฐ)" + else: + # Assume shadow is in feet if imperial, otherwise convert from meters to feet + shadow_ft = float(shadow) + height_ft = shadow_ft * math.tan(sun.alt) + return f"๐Ÿ“Object Height: {height_ft:.2f} ft (Shadow: {shadow_ft} ft, ๐Ÿ“Sun Alt: {sun_altitude:.2f}ยฐ)" + except Exception as e: + logger.error(f"Space: Error calculating height: {e}") + return NO_ALERTS \ No newline at end of file diff --git a/modules/survey.py b/modules/survey.py new file mode 100644 index 0000000..1d7645b --- /dev/null +++ b/modules/survey.py @@ -0,0 +1,273 @@ +# Survey Module for meshbot 2025 +# Provides a survey function to collect responses and put into a CSV file +# this module reads survey definitions from JSON files in the data/surveys directory +# Each survey is defined in a separate JSON file named _survey.json +# Example survey file: example_survey.json +# Example survey response file: example_responses.csv +# Each survey consists of multiple questions, which can be multiple choice, integer, or text +# Users can start a survey, answer questions, and end the survey +# Module acts like a game locking DM until the survey is complete or ended + +import json +import os # For file operations +import csv +from datetime import datetime +from collections import Counter +from modules.log import * + +allowedSurveys = [] # List of allowed survey names + +trap_list_survey = ("survey",) + +class SurveyModule: + def __init__(self): + self.base_dir = os.path.dirname(__file__) + self.survey_dir = os.path.join(self.base_dir, '..', 'data', 'surveys') # Directory for survey JSON files + self.response_dir = os.path.join(self.base_dir, '..', 'data', 'surveys') # Directory for survey response CSV files + self.surveys = {} + self.responses = {} + self.load_surveys() + + def load_surveys(self): + """Load all surveys from the surveys directory with _survey.json suffix.""" + global allowedSurveys + allowedSurveys.clear() + try: + for filename in os.listdir(self.survey_dir): + if filename.endswith('_survey.json'): + survey_name = filename[:-12] # Remove '_survey.json' + allowedSurveys.append(survey_name) + path = os.path.join(self.survey_dir, filename) + try: + with open(path, encoding='utf-8') as f: + self.surveys[survey_name] = json.load(f) + except FileNotFoundError: + logger.error(f"File not found: {path}") + self.surveys[survey_name] = [] + except json.JSONDecodeError: + logger.error(f"Error decoding JSON from file: {path}") + self.surveys[survey_name] = [] + except Exception as e: + logger.error(f"Survey: Error loading surveys: {e}") + + def start_survey(self, user_id, survey_name='example', location=None): + try: + """Begin a new survey session for a user.""" + if not survey_name: + survey_name = default_survey + if survey_name not in allowedSurveys: + return f"error: survey '{survey_name}' is not allowed." + self.responses[user_id] = { + 'survey_name': survey_name, + 'current_question': 0, + 'answers': [], + 'location': location if surveyRecordLocation and location is not None else 'N/A' + } + msg = f"'{survey_name}'๐Ÿ“survey\n" + msg += self.show_question(user_id) + msg += f"\nSend answer' or 'end'" + return msg + except Exception as e: + logger.error(f"Error starting survey for user {user_id}: {e}") + return "An error occurred while starting the survey. Please try again later." + + def show_question(self, user_id): + """Show the current question for the user, or end the survey.""" + survey_name = self.responses[user_id]['survey_name'] + current = self.responses[user_id]['current_question'] + questions = self.surveys.get(survey_name, []) + if current >= len(questions): + return self.end_survey(user_id) + question = questions[current] + msg = f"{question['question']}\n" + if question.get('type', 'multiple_choice') == 'multiple_choice': + for i, option in enumerate(question['options']): + msg += f"{chr(65+i)}. {option}\n" + elif question['type'] == 'integer': + msg += "(Please enter a number)\n" + elif question['type'] == 'text': + msg += "(Please enter your response)\n" + msg = msg.rstrip('\n') + return msg + + def save_responses(self, user_id): + """Save user responses to a CSV file.""" + survey_name = self.responses[user_id]['survey_name'] + if survey_name not in self.surveys: + logger.warning(f"Survey '{survey_name}' not loaded. Responses not saved.") + return + filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv') + try: + with open(filename, 'a', encoding='utf-8') as f: + # Always write: timestamp, userID, position, answers... + timestamp = datetime.now().strftime('%d%m%Y%H%M%S') + user_id_str = str(user_id) + location = self.responses[user_id].get('location', "N/A") + answers = list(map(str, self.responses[user_id]['answers'])) + row = [timestamp, user_id_str, str(location)] + answers + f.write(','.join(row) + '\n') + logger.info(f"Survey: Responses for user {user_id} saved for survey '{survey_name}' to {filename}.") + except Exception as e: + logger.error(f"Error saving responses to {filename}: {e}") + + def format_survey_results(self, results): + if isinstance(results, dict) and "error" in results: + return results["error"] + if not results: + return "No results found." + msg = "๐Ÿ“ŠSurvey Results:\n" + for idx, q in enumerate(results): + msg += f"\nQ{idx+1}: {q['question']}\n" + if q['type'] == 'multiple_choice': + for opt, count in q['summary'].items(): + msg += f" {opt}: {count}\n" + elif q['type'] == 'integer': + s = q['summary'] + msg += f" Count: {s['count']}, Avg: {s['average']:.2f}, Min: {s['min']}, Max: {s['max']}\n" + elif q['type'] == 'text': + msg += f" Responses: {q['summary']['responses_count']}\n" + return msg + + def get_survey_results(self, survey_name='example'): + if survey_name not in self.surveys: + return {"error": f"Survey '{survey_name}' not found."} + filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv') + questions = self.surveys[survey_name] + results = [] + try: + with open(filename, encoding='utf-8') as f: + reader = csv.reader(f) + lines = [] + for row in reader: + if not row or len(row) < 4: + continue + # If location field is split due to comma, join columns 2 and 3 + if row[2].startswith('[') and not row[2].endswith(']') and len(row) > 4: + location = row[2] + ',' + row[3] + answers = row[4:] + else: + location = row[2] + answers = row[3:] + lines.append(answers) + + for q_idx, question in enumerate(questions): + qtype = question.get('type', 'multiple_choice') + answers = [row[q_idx] for row in lines if len(row) > q_idx] + + summary = {} + if qtype == 'multiple_choice': + counts = Counter(answers) + summary = {chr(65+i): counts.get(chr(65+i), 0) for i in range(len(question.get('options', [])))} + + elif qtype == 'integer': + ints = [int(a) for a in answers if a.isdigit()] + summary = { + "count": len(ints), + "average": sum(ints)/len(ints) if ints else 0, + "min": min(ints) if ints else None, + "max": max(ints) if ints else None + } + + elif qtype == 'text': + summary = {"responses_count": len([a for a in answers if a.strip()])} + + + results.append({ + "question": question['question'], + "type": qtype, + "summary": summary + }) + + return results + except FileNotFoundError: + return {"error": f"No responses recorded yet for '{survey_name}'."} + except Exception as e: + logger.error(f"Error summarizing survey results: {e}") + return NO_ALERTS + + def answer(self, user_id, answer, location=None): + try: + """Record an answer and return the next question or end message.""" + if user_id not in self.responses: + return self.start_survey(user_id, location=location) + question_index = self.responses[user_id]['current_question'] + survey_name = self.responses[user_id]['survey_name'] + questions = self.surveys.get(survey_name, []) + if question_index < 0 or question_index >= len(questions): + return "No current question to answer." + question = questions[question_index] + qtype = question.get('type', 'multiple_choice') + if qtype == 'multiple_choice': + answer_char = answer.strip().upper()[:1] + if len(answer_char) != 1 or not answer_char.isalpha(): + return "Please answer with a letter (A, B, C, ...)." + option_index = ord(answer_char) - 65 + if 0 <= option_index < len(question['options']): + # Valid answer record letter, not index + self.responses[user_id]['answers'].append(answer_char) + self.responses[user_id]['current_question'] += 1 + return f"Recorded..\n" + self.show_question(user_id) + else: + print(f"Invalid option index {option_index} for question with {len(question['options'])} options. user entered '{answer}'") + return "Invalid answer option. Please try again." + elif qtype == 'integer': + try: + int_answer = int(answer) + self.responses[user_id]['answers'].append(str(int_answer)) + self.responses[user_id]['current_question'] += 1 + return f"Recorded..\n" + self.show_question(user_id) + except ValueError: + return "Please enter a valid integer." + elif qtype == 'text': + self.responses[user_id]['answers'].append(answer.strip()) + self.responses[user_id]['current_question'] += 1 + return f"Recorded..\n" + self.show_question(user_id) + else: + return f"error: unknown question type '{qtype}' and cannot record answer '{answer}'" + except Exception as e: + logger.error(f"Error recording answer for user {user_id}: {e}") + return "An error occurred while recording your answer. Please try again." + + def end_survey(self, user_id): + """End the survey for the user and save responses.""" + if user_id not in self.responses: + return "No active survey session to end." + self.save_responses(user_id) + self.responses.pop(user_id, None) + return "โœ… Survey complete. Thank you for your responses!" + + def quiz_report(self, survey_name='example'): + """ + Generate a quick poll report: counts of each answer per question. + Returns a string summary. + """ + filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv') + questions = self.surveys.get(survey_name, []) + if not questions: + logger.warning(f"No survey found for '{survey_name}'.") + return f"No survey found for '{survey_name}'." + all_answers = [] + try: + with open(filename, encoding='utf-8') as f: + for line in f: + parts = line.strip().split(',') + if surveyRecordID: + answers = [int(x) for x in parts[1:] if x.strip().isdigit()] + else: + answers = [int(x) for x in parts if x.strip().isdigit()] + all_answers.append(answers) + except FileNotFoundError: + logger.info(f"No responses recorded yet for '{survey_name}'.") + return "No responses recorded yet." + report = f"๐Ÿ“Š Poll Report for '{survey_name}':\n" + for q_idx, question in enumerate(questions): + counts = Counter(ans[q_idx] for ans in all_answers if len(ans) > q_idx) + report += f"\nQ{q_idx+1}: {question['question']}\n" + for opt_idx, option in enumerate(question.get('options', [])): + count = counts.get(opt_idx, 0) + report += f" {chr(65+opt_idx)}. {option}: {count}\n" + return report + +# Initialize the survey module +survey_module = SurveyModule() + diff --git a/modules/system.py b/modules/system.py index 598985c..bf6f0e4 100644 --- a/modules/system.py +++ b/modules/system.py @@ -7,19 +7,28 @@ import meshtastic.ble_interface import time import asyncio import random +import base64 +# not ideal but needed? import contextlib # for suppressing output on watchdog import io # for suppressing output on watchdog +# homebrew 'modules' from modules.log import * # Global Variables -debugMetadata = False # packet debug for non text messages -trap_list = ("cmd","cmd?") # default trap list +trap_list = ("cmd","cmd?","bannode",) # base commands help_message = "Bot CMD?:" asyncLoop = asyncio.new_event_loop() games_enabled = False multiPingList = [{'message_from_id': 0, 'count': 0, 'type': '', 'deviceID': 0, 'channel_number': 0, 'startCount': 0}] interface_retry_count = 3 +# Memory Management Constants +MAX_MSG_HISTORY = 250 +MAX_CMD_HISTORY = 250 +MAX_SEEN_NODES = 1000 +CLEANUP_INTERVAL = 86400 # 24 hours in seconds +GAMEDELAY = 3 * CLEANUP_INTERVAL # 3 days in seconds + # Ping Configuration if ping_enabled: # ping, pinging, ack, testing, test, pong @@ -27,11 +36,17 @@ if ping_enabled: trap_list = trap_list + trap_list_ping help_message = help_message + "ping" +# Echo Configuration +if enableEcho: + trap_list_echo = ("echo",) + trap_list = trap_list + trap_list_echo + help_message = help_message + ", echo" + # Sitrep Configuration if sitrep_enabled: - trap_list_sitrep = ("sitrep", "lheard", "sysinfo") + trap_list_sitrep = ("sitrep", "lheard", "sysinfo", "leaderboard") trap_list = trap_list + trap_list_sitrep - help_message = help_message + ", sitrep, sysinfo" + help_message = help_message + ", sitrep, sysinfo, leaderboard" # MOTD Configuration if motd_enabled: @@ -74,8 +89,8 @@ if enableCmdHistory: # Location Configuration if location_enabled: from modules.locationdata import * # from the spudgunman/meshing-around repo - trap_list = trap_list + trap_list_location + ("tide",) - help_message = help_message + ", whereami, wx, tide" + trap_list = trap_list + trap_list_location + help_message = help_message + ", whereami, wx, howfar" if enableGBalerts and not enableDEalerts: from modules.globalalert import * # from the spudgunman/meshing-around repo logger.warning(f"System: GB Alerts not functional at this time need to find a source API") @@ -92,20 +107,30 @@ if location_enabled: from modules.wx_meteo import * # from the spudgunman/meshing-around repo else: # NOAA only features - help_message = help_message + ", wxa" + help_message = help_message + ", wxa, wxalert" + + # USGS riverFlow Configuration + if riverListDefault != ['']: + help_message = help_message + ", riverflow" + + if repeater_lookup != False: + help_message = help_message + ", rlist" + + if solar_conditions_enabled: + help_message = help_message + ", howtall" # NOAA alerts needs location module if wxAlertBroadcastEnabled or emergencyAlertBrodcastEnabled or volcanoAlertBroadcastEnabled: from modules.locationdata import * # from the spudgunman/meshing-around repo # limited subset, this should be done better but eh.. trap_list = trap_list + ("wx", "wxa", "wxalert", "ea", "ealert", "valert") - help_message = help_message + ", wxalert, ealert, valert" + help_message = help_message + ", ealert, valert" -# NOAA Coastal Waters Forecasts PZZ -if pzzEnabled: +# NOAA Coastal Waters Forecasts +if coastalEnabled: from modules.locationdata import * # from the spudgunman/meshing-around repo - trap_list = trap_list + ("mwx",) - help_message = help_message + ", mwx" + trap_list = trap_list + ("mwx","tide",) + help_message = help_message + ", mwx, tide" # BBS Configuration if bbs_enabled: @@ -124,10 +149,16 @@ if dad_jokes_enabled: # Wikipedia Search Configuration if wikipedia_enabled: - import wikipedia # pip install wikipedia - trap_list = trap_list + ("wiki:", "wiki?",) + from modules.wiki import * # from the spudgunman/meshing-around repo + trap_list = trap_list + ("wiki:",) help_message = help_message + ", wiki:" +# RSS Feed Configuration +if rssEnable: + from modules.rss import * # from the spudgunman/meshing-around repo + trap_list = trap_list + ("readrss",) + help_message = help_message + ", readrss" + # LLM Configuration if llm_enabled: from modules.llm import * # from the spudgunman/meshing-around repo @@ -178,6 +209,22 @@ if hamtest_enabled: trap_list = trap_list + ("hamtest",) games_enabled = True +if tictactoe_enabled: + from modules.games.tictactoe import * # from the spudgunman/meshing-around repo + trap_list = trap_list + ("tictactoe","tic-tac-toe",) + +if quiz_enabled: + from modules.games.quiz import * # from the spudgunman/meshing-around repo + trap_list = trap_list + trap_list_quiz # items quiz, q: + help_message = help_message + ", quiz" + games_enabled = True + +if survey_enabled: + from modules.survey import * # from the spudgunman/meshing-around repo + trap_list = trap_list + trap_list_survey # items survey, s: + help_message = help_message + ", survey" + games_enabled = True + # Games Configuration if games_enabled is True: help_message = help_message + ", games" @@ -189,7 +236,8 @@ if games_enabled is True: if lemonade_enabled: gamesCmdList += "lemonStand, " if gTnW_enabled: - trap_list = trap_list + ("globalthermonuclearwar",) + trap_list = trap_list + ("globalthermonuclearwar","chess") + gamesCmdList += "chess, " if blackjack_enabled: gamesCmdList += "blackJack, " if videoPoker_enabled: @@ -202,6 +250,8 @@ if games_enabled is True: gamesCmdList += "hangman, " if hamtest_enabled: gamesCmdList += "hamTest, " + if tictactoe_enabled: + gamesCmdList += "ticTacToe, " gamesCmdList = gamesCmdList[:-2] # remove the last comma else: gamesCmdList = "" @@ -236,8 +286,11 @@ if checklist_enabled: if radio_detection_enabled: from modules.radio import * # from the spudgunman/meshing-around repo +if voxDetectionEnabled: + from modules.radio import * # from the spudgunman/meshing-around repo + # File Monitor Configuration -if file_monitor_enabled or read_news_enabled or bee_enabled: +if file_monitor_enabled or read_news_enabled or bee_enabled or enable_runShellCmd or cmdShellSentryAlerts: from modules.filemon import * # from the spudgunman/meshing-around repo if read_news_enabled: trap_list = trap_list + trap_list_filemon # items readnews @@ -245,6 +298,9 @@ if file_monitor_enabled or read_news_enabled or bee_enabled: # Bee Configuration uses file monitor module if bee_enabled: trap_list = trap_list + ("๐Ÿ",) + # x: command for shell access + if enable_runShellCmd and allowXcmd: + trap_list = trap_list + ("x:",) # clean up the help message help_message = help_message.split(", ") @@ -260,10 +316,29 @@ if ble_count > 1: logger.critical(f"System: Multiple BLE interfaces detected. Only one BLE interface is allowed. Exiting") exit() +def xor_hash(data: bytes) -> int: + """Compute an XOR hash from bytes.""" + result = 0 + for char in data: + result ^= char + return result + +def generate_hash(name: str, key: str) -> int: + """generate the channel number by hashing the channel name and psk""" + if key == "AQ==": + key = "1PG7OiApB1nwvP+rz05pAQ==" + replaced_key = key.replace("-", "+").replace("_", "/") + key_bytes = base64.b64decode(replaced_key.encode("utf-8")) + h_name = xor_hash(bytes(name, "utf-8")) + h_key = xor_hash(key_bytes) + result: int = h_name ^ h_key + return result + # Initialize interfaces logger.debug(f"System: Initializing Interfaces") interface1 = interface2 = interface3 = interface4 = interface5 = interface6 = interface7 = interface8 = interface9 = None retry_int1 = retry_int2 = retry_int3 = retry_int4 = retry_int5 = retry_int6 = retry_int7 = retry_int8 = retry_int9 = False +myNodeNum1 = myNodeNum2 = myNodeNum3 = myNodeNum4 = myNodeNum5 = myNodeNum6 = myNodeNum7 = myNodeNum8 = myNodeNum9 = 777 max_retry_count1 = max_retry_count2 = max_retry_count3 = max_retry_count4 = max_retry_count5 = max_retry_count6 = max_retry_count7 = max_retry_count8 = max_retry_count9 = interface_retry_count for i in range(1, 10): interface_type = globals().get(f'interface{i}_type') @@ -275,7 +350,20 @@ for i in range(1, 10): if interface_type == 'serial': globals()[f'interface{i}'] = meshtastic.serial_interface.SerialInterface(globals().get(f'port{i}')) elif interface_type == 'tcp': - globals()[f'interface{i}'] = meshtastic.tcp_interface.TCPInterface(globals().get(f'hostname{i}')) + host = globals().get(f'hostname{i}', '127.0.0.1') + port = 4403 + + # Allow host:port format + if isinstance(host, str) and ':' in host: + maybe_host, maybe_port = host.rsplit(':', 1) + if maybe_port.isdigit(): + host = maybe_host + try: + port = int(maybe_port) + except ValueError: + port = 4403 + + globals()[f'interface{i}'] = meshtastic.tcp_interface.TCPInterface(hostname=host, portNumber=port) elif interface_type == 'ble': globals()[f'interface{i}'] = meshtastic.ble_interface.BLEInterface(globals().get(f'mac{i}')) else: @@ -296,8 +384,114 @@ for i in range(1, 10): else: globals()[f'myNodeNum{i}'] = 777 +# Fetch channel list from each device +channel_list = [] +for i in range(1, 10): + if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'): + try: + node = globals()[f'interface{i}'].getNode('^local') + channels = node.channels + channel_dict = {} + for channel in channels: + if hasattr(channel, 'role') and channel.role: + channel_name = getattr(channel.settings, 'name', '').strip() + channel_number = getattr(channel, 'index', 0) + # Only add channels with a non-empty name + if channel_name: + channel_dict[channel_name] = channel_number + channel_list.append({ + "interface_id": i, + "channels": channel_dict + }) + logger.debug(f"System: Fetched Channel List from Device{i}") + except Exception as e: + logger.error(f"System: Error fetching channel list from Device{i}: {e}") + +# add channel hash to channel_list +for device in channel_list: + interface_id = device["interface_id"] + interface = globals().get(f'interface{interface_id}') + for channel_name, channel_number in device["channels"].items(): + psk_base64 = "AQ==" # default PSK + channel_hash = generate_hash(channel_name, psk_base64) + # add hash to the channel entry in channel_list under key 'hash' + for entry in channel_list: + if entry["interface_id"] == interface_id: + entry["channels"][channel_name] = { + "number": channel_number, + "hash": channel_hash + } + #### FUN-ctions #### +def cleanup_memory(): + """Clean up memory by limiting list sizes and removing stale entries""" + global cmdHistory, seenNodes, multiPingList, waitingXroom + current_time = time.time() + + try: + # Limit cmdHistory size + if 'cmdHistory' in globals() and len(cmdHistory) > MAX_CMD_HISTORY: + cmdHistory = cmdHistory[-(MAX_CMD_HISTORY - 50):] # keep the most recent 50 entries + logger.debug(f"System: Trimmed cmdHistory to {len(cmdHistory)} entries") + + # limit waitingXroom size by time + if 'waitingXroom' in globals(): + initial_count = len(waitingXroom) + to_delete = [key for key, (_, _, ts) in waitingXroom.items() if current_time - ts.timestamp() > xCmd2factor_timeout] + for key in to_delete: + del waitingXroom[key] + cleaned_count = initial_count - len(waitingXroom) + if cleaned_count > 0: + logger.debug(f"System: Cleaned up {cleaned_count} stale entries from waitingXroom") + + # Clean up old seenNodes entries + if 'seenNodes' in globals(): + initial_count = len(seenNodes) + if len(seenNodes) > MAX_SEEN_NODES: + # cut the list in half if it exceeds max size + seenNodes = seenNodes[-(MAX_SEEN_NODES // 2):] + logger.warning(f"System: Trimmed seenNodes to {len(seenNodes)} entries due to size limit of {MAX_SEEN_NODES}") + + # Clean up stale game tracker entries + cleanup_game_trackers(current_time) + + # # Clean up multiPingList of completed or stale entries + # if 'multiPingList' in globals(): + # multiPingList[:] = [ping for ping in multiPingList + # if ping.get('message_from_id', 0) != 0 and + # ping.get('count', 0) > 0] + + except Exception as e: + logger.error(f"System: Error during memory cleanup: {e}") + +def cleanup_game_trackers(current_time): + """Clean up all game tracker lists of stale entries""" + try: + # List of game tracker global variable names + tracker_names = [ + 'dwPlayerTracker', 'lemonadeTracker', 'jackTracker', + 'vpTracker', 'mindTracker', 'golfTracker', + 'hangmanTracker', 'hamtestTracker', 'tictactoeTracker', 'surveyTracker' + ] + + for tracker_name in tracker_names: + if tracker_name in globals(): + tracker = globals()[tracker_name] + if isinstance(tracker, list): + initial_count = len(tracker) + # Remove entries older than GAMEDELAY + globals()[tracker_name] = [ + entry for entry in tracker + if current_time - entry.get('last_played', entry.get('time', 0)) < GAMEDELAY + ] + cleaned_count = initial_count - len(globals()[tracker_name]) + if cleaned_count > 0: + logger.debug(f"System: Cleaned up {cleaned_count} stale entries from {tracker_name}") + + except Exception as e: + logger.error(f"System: Error cleaning up game trackers: {e}") + def decimal_to_hex(decimal_number): return f"!{decimal_number:08x}" @@ -317,7 +511,6 @@ def get_name_from_number(number, type='long', nodeInt=1): name = str(decimal_to_hex(number)) # If name not found, use the ID as string return name - def get_num_from_short_name(short_name, nodeInt=1): interface = globals()[f'interface{nodeInt}'] # Get the node number from the short name, converting all to lowercase for comparison (good practice?) @@ -394,93 +587,115 @@ def get_node_list(nodeInt=1): return node_list -def get_node_location(nodeID, nodeInt=1, channel=0): +def get_node_location(nodeID, nodeInt=1, channel=0, round_digits=2): + """ + Returns [latitude, longitude] for a node. + - Always returns a fuzzed (rounded) config location as fallback. + - returns their actual position if available, else fuzzed config location. + """ interface = globals()[f'interface{nodeInt}'] - # Get the location of a node by its number from nodeDB on device - # if no location data, return default location - latitude = latitudeValue - longitude = longitudeValue - position = [latitudeValue,longitudeValue] + + fuzzed_position = [round(latitudeValue, round_digits), round(longitudeValue, round_digits)] + config_position = [latitudeValue, longitudeValue] + + # Try to find an exact location for the requested node if interface.nodes: for node in interface.nodes.values(): if nodeID == node['num']: - if 'position' in node and node['position'] is not {}: + pos = node.get('position') + if ( + pos and isinstance(pos, dict) + and pos.get('latitude') is not None + and pos.get('longitude') is not None + ): try: + # Got a valid position + latitude = pos['latitude'] + longitude = pos['longitude'] + if fuzzItAll: + latitude = round(latitude, round_digits) + longitude = round(longitude, round_digits) + logger.debug(f"System: Fuzzed location data for {nodeID} is {latitude}, {longitude}") + logger.debug(f"System: Location data for {nodeID} is {latitude}, {longitude}") + return [latitude, longitude] + except Exception as e: + logger.warning(f"System: Error processing position for node {nodeID}: {e}") + + if fuzz_config_location: + # Return fuzzed config location if no valid position found + return fuzzed_position + else: + return config_position + +async def get_closest_nodes(nodeInt=1,returnCount=3, channel=publicChannel): + interface = globals()[f'interface{nodeInt}'] + node_list = [] + + if interface.nodes: + for node in interface.nodes.values(): + if 'position' in node: + try: + nodeID = node['num'] latitude = node['position']['latitude'] longitude = node['position']['longitude'] - logger.debug(f"System: location data for {nodeID} is {latitude},{longitude}") - position = [latitude,longitude] + + #lastheard time in unix time + lastheard = node.get('lastHeard', 0) + #if last heard is over 24 hours ago, ignore the node + if lastheard < (time.time() - 86400): + continue + + # Calculate distance to node from config.ini location + distance = round(geopy.distance.geodesic((latitudeValue, longitudeValue), (latitude, longitude)).m, 2) + + if (distance < sentry_radius): + if (nodeID not in [globals().get(f'myNodeNum{i}') for i in range(1, 10)]) and str(nodeID) not in sentryIgnoreList: + node_list.append({'id': nodeID, 'latitude': latitude, 'longitude': longitude, 'distance': distance}) + except Exception as e: - logger.debug(f"System: No location data for {nodeID} use default location") - return position + pass else: - logger.debug(f"System: No location data for {nodeID} using default location") - # request location data - # try: - # logger.debug(f"System: Requesting location data for {number}") - # interface.sendPosition(destinationId=number, wantResponse=False, channelIndex=channel) - # except Exception as e: - # logger.error(f"System: Error requesting location data for {number}. Error: {e}") - return position + # request location data currently blocking needs to be async + reqLocationEnabled = False + if reqLocationEnabled: + try: + logger.debug(f"System: Requesting location data for {node['id']}, lastHeard: {node.get('lastHeard', 'N/A')}") + # if not a interface node + if node['num'] in [globals().get(f'myNodeNum{i}') for i in range(1, 10)]: + ignore = True + else: + # one idea is to send a ping to the node to request location data for if or when, ask again later + interface.sendPosition(destinationId=node['id'], wantResponse=False, channelIndex=channel) + # wayyy too fast async wait + + # send a traceroute request + interface.sendTraceRoute(destinationId=node['id'], channelIndex=channel, wantResponse=False) + except Exception as e: + logger.error(f"System: Error requesting location data for {node['id']}. Error: {e}") + # sort by distance closest + #node_list.sort(key=lambda x: (x['latitude']-latitudeValue)**2 + (x['longitude']-longitudeValue)**2) + node_list.sort(key=lambda x: x['distance']) + # return the first 3 closest nodes by default + return node_list[:returnCount] else: - logger.warning(f"System: Location for NodeID {nodeID} not found in nodeDb") - return position - - -def get_closest_nodes(nodeInt=1,returnCount=3): - interface = globals()[f'interface{nodeInt}'] - node_list = [] - - if interface.nodes: - for node in interface.nodes.values(): - if 'position' in node: - try: - nodeID = node['num'] - latitude = node['position']['latitude'] - longitude = node['position']['longitude'] - - #lastheard time in unix time - lastheard = node.get('lastHeard', 0) - #if last heard is over 24 hours ago, ignore the node - if lastheard < (time.time() - 86400): - continue - - # Calculate distance to node from config.ini location - distance = round(geopy.distance.geodesic((latitudeValue, longitudeValue), (latitude, longitude)).m, 2) - - if (distance < sentry_radius): - if (nodeID not in [globals().get(f'myNodeNum{i}') for i in range(1, 10)]) and str(nodeID) not in sentryIgnoreList: - node_list.append({'id': nodeID, 'latitude': latitude, 'longitude': longitude, 'distance': distance}) - - except Exception as e: - pass - # else: - # # request location data - # try: - # logger.debug(f"System: Requesting location data for {node['id']}") - # interface.sendPosition(destinationId=node['id'], wantResponse=False, channelIndex=publicChannel) - # except Exception as e: - # logger.error(f"System: Error requesting location data for {node['id']}. Error: {e}") - - # sort by distance closest - #node_list.sort(key=lambda x: (x['latitude']-latitudeValue)**2 + (x['longitude']-longitudeValue)**2) - node_list.sort(key=lambda x: x['distance']) - # return the first 3 closest nodes by default - return node_list[:returnCount] - else: - logger.warning(f"System: No nodes found in closest_nodes on interface {nodeInt}") - return ERROR_FETCHING_DATA + logger.warning(f"System: No nodes found in closest_nodes on interface {nodeInt}") + return ERROR_FETCHING_DATA -def handleFavoritNode(nodeInt=1, nodeID=0, aor=False): - #aor is add or remove if True add, if False remove +def handleFavoriteNode(nodeInt=1, nodeID=0, aor=False): + # Add or remove a favorite node for the given interface. aor: True to add, False to remove. interface = globals()[f'interface{nodeInt}'] myNodeNumber = globals().get(f'myNodeNum{nodeInt}') - if aor: - interface.getNode(myNodeNumber).addFavorite(nodeID) - logger.info(f"System: Added {nodeID} to favorites") - else: - interface.getNode(myNodeNumber).removeFavorite(nodeID) - logger.info(f"System: Removed {nodeID} from favorites") + try: + if aor: + result = interface.getNode(myNodeNumber).setFavorite(nodeID) + logger.info(f"System: Added {nodeID} to favorites for device {nodeInt}") + else: + result = interface.getNode(myNodeNumber).removeFavorite(nodeID) + logger.info(f"System: Removed {nodeID} from favorites for device {nodeInt}") + return result + except Exception as e: + logger.error(f"System: Error handling favorite node {nodeID} on device {nodeInt}: {e}") + return None def getFavoritNodes(nodeInt=1): interface = globals()[f'interface{nodeInt}'] @@ -501,177 +716,195 @@ def handleSentinelIgnore(nodeInt=1, nodeID=0, aor=False): def messageChunker(message): message_list = [] - if len(message) > MESSAGE_CHUNK_SIZE: - parts = message.split('\n') - for part in parts: - part = part.strip() - # remove empty parts - if not part: - continue - # if part is under the MESSAGE_CHUNK_SIZE, add it to the list - if len(part) < MESSAGE_CHUNK_SIZE: - message_list.append(part) - else: - # split the part into chunks - current_chunk = '' - sentences = [] - sentence = '' - for char in part: - sentence += char - # if char in '.!?': - # sentences.append(sentence.strip()) - # sentence = '' - if sentence: - sentences.append(sentence.strip()) + try: + if len(message) > MESSAGE_CHUNK_SIZE: + # Split the message into parts by new lines + parts = message.split('\n') + for part in parts: + part = part.strip() + # remove empty parts + if not part: + continue + # if part is under the MESSAGE_CHUNK_SIZE, add it to the list + if len(part) < MESSAGE_CHUNK_SIZE: + message_list.append(part) + else: + # split the part into chunks + current_chunk = '' + sentences = [] + sentence = '' + for char in part: + sentence += char + # if char in '.!?': + # sentences.append(sentence.strip()) + # sentence = '' + if sentence: + sentences.append(sentence.strip()) - for sentence in sentences: - sentence = sentence.replace(' ', ' ') - # remove empty sentences - if not sentence: - continue - # remove junk sentences and append to the previous sentence this may exceed the MESSAGE_CHUNK_SIZE by 3 - if len(sentence) < 4: - if current_chunk: - current_chunk += sentence - else: + for sentence in sentences: + sentence = sentence.replace(' ', ' ') + # remove empty sentences + if not sentence: + continue + # remove junk sentences and append to the previous sentence this may exceed the MESSAGE_CHUNK_SIZE by 3char + if len(sentence) < 4: + if current_chunk: + current_chunk += sentence + else: + current_chunk = sentence + continue + + # if sentence is too long, split it by words + if len(current_chunk) + len(sentence) > MESSAGE_CHUNK_SIZE: + if current_chunk: + message_list.append(current_chunk) current_chunk = sentence - continue - - # if sentence is too long, split it by words - if len(current_chunk) + len(sentence) > MESSAGE_CHUNK_SIZE: - if current_chunk: - message_list.append(current_chunk) - current_chunk = sentence - else: - if current_chunk: - current_chunk += ' ' + sentence else: - current_chunk = sentence - if current_chunk: - message_list.append(current_chunk) + if current_chunk: + current_chunk += ' ' + sentence + else: + current_chunk = sentence + if current_chunk: + message_list.append(current_chunk) - # Consolidate any adjacent messages that can fit in a single chunk. - idx = 0 - while idx < len(message_list) - 1: - if len(message_list[idx]) + len(message_list[idx+1]) < MESSAGE_CHUNK_SIZE: - message_list[idx] += '\n' + message_list[idx+1] - del message_list[idx+1] - else: - idx += 1 + # Consolidate any adjacent messages that can fit in a single chunk. + idx = 0 + while idx < len(message_list) - 1: + if len(message_list[idx]) + len(message_list[idx+1]) < MESSAGE_CHUNK_SIZE: + message_list[idx] += '\n' + message_list[idx+1] + del message_list[idx+1] + else: + idx += 1 - # Ensure no chunk exceeds MESSAGE_CHUNK_SIZE - final_message_list = [] - for chunk in message_list: - while len(chunk) > MESSAGE_CHUNK_SIZE: - # Find the last space within the chunk size limit - split_index = chunk.rfind(' ', 0, MESSAGE_CHUNK_SIZE) - if split_index == -1: - split_index = MESSAGE_CHUNK_SIZE - final_message_list.append(chunk[:split_index]) - chunk = chunk[split_index:].strip() - if chunk: - final_message_list.append(chunk) + # Ensure no chunk exceeds MESSAGE_CHUNK_SIZE + final_message_list = [] + for chunk in message_list: + while len(chunk) > MESSAGE_CHUNK_SIZE: + # Find the last space within the chunk size limit + split_index = chunk.rfind(' ', 0, MESSAGE_CHUNK_SIZE) + if split_index == -1: + split_index = MESSAGE_CHUNK_SIZE + final_message_list.append(chunk[:split_index]) + chunk = chunk[split_index:].strip() + if chunk: + final_message_list.append(chunk) - # Calculate the total length of the message - total_length = sum(len(chunk) for chunk in final_message_list) - num_chunks = len(final_message_list) - logger.debug(f"System: Splitting #chunks: {num_chunks}, Total length: {total_length}") - return final_message_list + # Calculate the total length of the message + total_length = sum(len(chunk) for chunk in final_message_list) + num_chunks = len(final_message_list) + logger.debug(f"System: Splitting #chunks: {num_chunks}, Total length: {total_length}") + return final_message_list - return message + return message + except Exception as e: + logger.warning(f"System: Exception during message chunking: {e} (message length: {len(message)})") def send_message(message, ch, nodeid=0, nodeInt=1, bypassChuncking=False): # Send a message to a channel or DM interface = globals()[f'interface{nodeInt}'] # Check if the message is empty - if message == "" or message == None or len(message) == 0: + if message == "" or message is None or len(message) == 0: return False - if not bypassChuncking: - # Split the message into chunks if it exceeds the MESSAGE_CHUNK_SIZE - message_list = messageChunker(message) - else: - message_list = [message] + try: + # Force chunking and log if message exceeds maxBuffer + if len(message.encode('utf-8')) > maxBuffer: + logger.debug(f"System: Message length {len(message.encode('utf-8'))} exceeds maxBuffer{maxBuffer}, forcing chunking.") + message_list = messageChunker(message) + elif not bypassChuncking: + # Split the message into chunks if it exceeds the MESSAGE_CHUNK_SIZE + message_list = messageChunker(message) + else: + message_list = [message] - if isinstance(message_list, list): - # Send the message to the channel or DM - total_length = sum(len(chunk) for chunk in message_list) - num_chunks = len(message_list) - for m in message_list: - chunkOf = f"{message_list.index(m)+1}/{num_chunks}" + if isinstance(message_list, list): + # Send the message to the channel or DM + total_length = sum(len(chunk) for chunk in message_list) + num_chunks = len(message_list) + for m in message_list: + chunkOf = f"{message_list.index(m)+1}/{num_chunks}" + if nodeid == 0: + # Send to channel + if wantAck: + logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + f"req.ACK " + f"Chunker{chunkOf} SendingChannel: " + CustomFormatter.white + m.replace('\n', ' ')) + interface.sendText(text=m, channelIndex=ch, wantAck=True) + else: + logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + f"Chunker{chunkOf} SendingChannel: " + CustomFormatter.white + m.replace('\n', ' ')) + interface.sendText(text=m, channelIndex=ch) + else: + # Send to DM + if wantAck: + logger.info(f"Device:{nodeInt} " + CustomFormatter.red + f"req.ACK " + f"Chunker{chunkOf} Sending DM: " + CustomFormatter.white + m.replace('\n', ' ') + CustomFormatter.purple +\ + " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") + interface.sendText(text=m, channelIndex=ch, destinationId=nodeid, wantAck=True) + else: + logger.info(f"Device:{nodeInt} " + CustomFormatter.red + f"Chunker{chunkOf} Sending DM: " + CustomFormatter.white + m.replace('\n', ' ') + CustomFormatter.purple +\ + " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") + interface.sendText(text=m, channelIndex=ch, destinationId=nodeid) + + # Throttle the message sending to prevent spamming the device + if (message_list.index(m)+1) % 4 == 0: + time.sleep(responseDelay + 1) + if (message_list.index(m)+1) % 5 == 0: + logger.warning(f"System: throttling rate Interface{nodeInt} on {chunkOf}") + + # wait an amount of time between sending each split message + time.sleep(splitDelay) + else: # message is less than MESSAGE_CHUNK_SIZE characters if nodeid == 0: # Send to channel if wantAck: - logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + f"req.ACK " + f"Chunker{chunkOf} SendingChannel: " + CustomFormatter.white + m.replace('\n', ' ')) - interface.sendText(text=m, channelIndex=ch, wantAck=True) + logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + "req.ACK " + "SendingChannel: " + CustomFormatter.white + message.replace('\n', ' ')) + interface.sendText(text=message, channelIndex=ch, wantAck=True) else: - logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + f"Chunker{chunkOf} SendingChannel: " + CustomFormatter.white + m.replace('\n', ' ')) - interface.sendText(text=m, channelIndex=ch) + logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + "SendingChannel: " + CustomFormatter.white + message.replace('\n', ' ')) + interface.sendText(text=message, channelIndex=ch) else: # Send to DM if wantAck: - logger.info(f"Device:{nodeInt} " + CustomFormatter.red + f"req.ACK " + f"Chunker{chunkOf} Sending DM: " + CustomFormatter.white + m.replace('\n', ' ') + CustomFormatter.purple +\ - " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") - interface.sendText(text=m, channelIndex=ch, destinationId=nodeid, wantAck=True) + logger.info(f"Device:{nodeInt} " + CustomFormatter.red + "req.ACK " + "Sending DM: " + CustomFormatter.white + message.replace('\n', ' ') + CustomFormatter.purple +\ + " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") + interface.sendText(text=message, channelIndex=ch, destinationId=nodeid, wantAck=True) else: - logger.info(f"Device:{nodeInt} " + CustomFormatter.red + f"Chunker{chunkOf} Sending DM: " + CustomFormatter.white + m.replace('\n', ' ') + CustomFormatter.purple +\ + logger.info(f"Device:{nodeInt} " + CustomFormatter.red + "Sending DM: " + CustomFormatter.white + message.replace('\n', ' ') + CustomFormatter.purple +\ " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") - interface.sendText(text=m, channelIndex=ch, destinationId=nodeid) - + interface.sendText(text=message, channelIndex=ch, destinationId=nodeid) # Throttle the message sending to prevent spamming the device - if (message_list.index(m)+1) % 4 == 0: - time.sleep(responseDelay + 1) - if (message_list.index(m)+1) % 5 == 0: - logger.warning(f"System: throttling rate Interface{nodeInt} on {chunkOf}") - - - # wait an amout of time between sending each split message - time.sleep(splitDelay) - else: # message is less than MESSAGE_CHUNK_SIZE characters - if nodeid == 0: - # Send to channel - if wantAck: - logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + "req.ACK " + "SendingChannel: " + CustomFormatter.white + message.replace('\n', ' ')) - interface.sendText(text=message, channelIndex=ch, wantAck=True) - else: - logger.info(f"Device:{nodeInt} Channel:{ch} " + CustomFormatter.red + "SendingChannel: " + CustomFormatter.white + message.replace('\n', ' ')) - interface.sendText(text=message, channelIndex=ch) - else: - # Send to DM - if wantAck: - logger.info(f"Device:{nodeInt} " + CustomFormatter.red + "req.ACK " + "Sending DM: " + CustomFormatter.white + message.replace('\n', ' ') + CustomFormatter.purple +\ - " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") - interface.sendText(text=message, channelIndex=ch, destinationId=nodeid, wantAck=True) - else: - logger.info(f"Device:{nodeInt} " + CustomFormatter.red + "Sending DM: " + CustomFormatter.white + message.replace('\n', ' ') + CustomFormatter.purple +\ - " To: " + CustomFormatter.white + f"{get_name_from_number(nodeid, 'long', nodeInt)}") - interface.sendText(text=message, channelIndex=ch, destinationId=nodeid) - return True - -def get_wikipedia_summary(search_term): - wikipedia_search = wikipedia.search(search_term, results=3) - wikipedia_suggest = wikipedia.suggest(search_term) - #wikipedia_aroundme = wikipedia.geosearch(location[0], location[1], results=3) - #logger.debug(f"System: Wikipedia Nearby:{wikipedia_aroundme}") - - if len(wikipedia_search) == 0: - logger.warning(f"System: No Wikipedia Results for:{search_term}") - return ERROR_FETCHING_DATA - - try: - logger.debug(f"System: Searching Wikipedia for:{search_term}, First Result:{wikipedia_search[0]}, Suggest Word:{wikipedia_suggest}") - summary = wikipedia.summary(search_term, sentences=wiki_return_limit, auto_suggest=False, redirect=True) - except wikipedia.DisambiguationError as e: - logger.warning(f"System: Disambiguation Error for:{search_term} trying {wikipedia_search[0]}") - summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True) - except wikipedia.PageError as e: - logger.warning(f"System: Wikipedia Page Error for:{search_term} {e} trying {wikipedia_search[0]}") - summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True) + time.sleep(responseDelay) + return True except Exception as e: - logger.warning(f"System: Error with Wikipedia for:{search_term} {e}") - return ERROR_FETCHING_DATA - - return summary + logger.error(f"System: Exception during send_message: {e} (message length: {len(message)})") + return False + +def send_raw_bytes(nodeid, raw_bytes, nodeInt=1, channel=0, portnum=256, want_ack=True): + # Send raw bytes to a node using the Meshtastic interface. + interface = globals()[f'interface{nodeInt}'] + try: + interface.sendData( + raw_bytes, + destinationId=nodeid, + portNum=portnum, + channelIndex=channel, + wantAck=want_ack + ) + # Throttle the message sending to prevent spamming the device + logger.debug(f"System: Sent raw bytes to {nodeid} on portnum {portnum} via Device{nodeInt}") + time.sleep(responseDelay) + return True + except Exception as e: + logger.error(f"System: Error sending raw bytes to {nodeid} via Device{nodeInt}: {e} bytes: {raw_bytes}") + return False + +def decode_raw_bytes(raw_bytes): + # Decode raw bytes received from a Meshtastic device. + try: + decoded_message = raw_bytes.decode('utf-8', errors='ignore') + # reminder for a synch word check or crc check if needed later + logger.debug(f"Decoded raw bytes: {decoded_message}") + return decoded_message + except Exception as e: + logger.debug(f"System: Error decoding raw bytes: {e} bytes: {raw_bytes}") + return "" def messageTrap(msg): # Check if the message contains a trap word, this is the first filter for listning to messages @@ -680,13 +913,24 @@ def messageTrap(msg): # Split Message on assumed words spaces m for m = msg.split(" ") # t in trap_list, built by the config and system.py not the user message_list=msg.split(" ") + + if cmdBang: + # check for ! at the start of the message to force a command + if not message_list[0].startswith('!'): + return False + else: + message_list[0] = message_list[0][1:] + for m in message_list: for t in trap_list: - # if word in message is in the trap list, return True - if t.lower() == m.lower(): - return True - if cmdBang and m.startswith("!"): - return True + if not explicitCmd: + # if word in message is in the trap list, return True + if t.lower() == m.lower(): + return True + else: + # if the index 0 of the message is a word in the trap list, return True + if t.lower() == m.lower() and message_list.index(m) == 0: + return True # if no trap words found, run a search for near misses like ping? or cmd? for m in message_list: for t in range(len(trap_list)): @@ -694,6 +938,119 @@ def messageTrap(msg): return True return False +def stringSafeCheck(s): + # Check if a string is safe to use, no control characters or non-printable characters + soFarSoGood = True + if not all(c.isprintable() or c.isspace() for c in s): + return False + if any(ord(c) < 32 and c not in '\n\r\t' for c in s): + return False + if any(c in s for c in ['\x0b', '\x0c', '\x1b']): + return False + if len(s) > 1000: + return False + injection_chars = [';', '|', '../'] + if any(char in s for char in injection_chars): + return False + return soFarSoGood + +def save_bbsBanList(): + # save the bbs_ban_list to file + try: + with open('data/bbs_ban_list.txt', 'w') as f: + for node in bbs_ban_list: + f.write(f"{node}\n") + logger.debug("System: BBS ban list saved") + except Exception as e: + logger.error(f"System: Error saving BBS ban list: {e}") + +def load_bbsBanList(): + global bbs_ban_list + loaded_list = [] + try: + with open('data/bbs_ban_list.txt', 'r') as f: + loaded_list = [line.strip() for line in f if line.strip()] + logger.debug("System: BBS ban list loaded from file") + except FileNotFoundError: + config_val = config['bbs'].get('bbs_ban_list', '') + if config_val: + loaded_list = [x.strip() for x in config_val.split(',') if x.strip()] + logger.debug("System: No BBS ban list file found, loaded from config or started empty") + except Exception as e: + logger.error(f"System: Error loading BBS ban list: {e}") + + # Merge loaded_list into bbs_ban_list, only adding new entries + for node in loaded_list: + if node not in bbs_ban_list: + bbs_ban_list.append(node) + +def isNodeAdmin(nodeID): + # check if the nodeID is in the bbs_admin_list + if bbs_admin_list != ['']: + for admin in bbs_admin_list: + if str(nodeID) == admin: + return True + else: + return True + return False + +def isNodeBanned(nodeID): + # check if the nodeID is in the bbs_ban_list + for banned in bbs_ban_list: + if str(nodeID) == banned: + return True + return False + +def handle_bbsban(message, message_from_id, isDM): + msg = "" + if not isDM: + return "๐Ÿค–only available in a Direct Message๐Ÿ“ต" + if not isNodeAdmin(message_from_id): + return NO_ALERTS + if "?" in message: + return "Ban or unban a node from posting to the BBS. Example: bannode add 1234567890 or bannode remove 1234567890" + + parts = message.lower().split() + if len(parts) < 2 or parts[0] != "bannode": + return "Please specify add, remove, or list. Example: bannode add 1234567890" + + action = parts[1] + + if action == "list": + load_bbsBanList() # Always reload from file for latest list + if bbs_ban_list: + return "BBS Ban List:\n" + "\n".join(bbs_ban_list) + else: + return "The BBS ban list is currently empty." + + if len(parts) < 3: + return "Please specify add or remove and a node number. Example: bannode add 1234567890" + + node_id = parts[2].strip() + if not node_id.isdigit(): + return "Invalid node number. Please provide a numeric node ID." + + if action == "add": + if node_id not in bbs_ban_list: + bbs_ban_list.append(node_id) + save_bbsBanList() + logger.warning(f"System: {message_from_id} added {node_id} to the BBS ban list") + msg = f"Node {node_id} added to the BBS ban list" + else: + msg = f"Node {node_id} is already in the BBS ban list" + elif action == "remove": + if node_id in bbs_ban_list: + bbs_ban_list.remove(node_id) + save_bbsBanList() + logger.warning(f"System: {message_from_id} removed {node_id} from the BBS ban list") + msg = f"Node {node_id} removed from the BBS ban list" + else: + msg = f"Node {node_id} is not in the BBS ban list" + else: + msg = "Invalid action. Please use 'add', 'remove', or 'list'." + + return msg + def handleMultiPing(nodeID=0, deviceID=1): global multiPingList if len(multiPingList) > 1: @@ -734,7 +1091,6 @@ def handleMultiPing(nodeID=0, deviceID=1): # send the DM send_message(f"๐Ÿ”‚{count} {type}", channel_number, message_id_from, deviceID, bypassChuncking=True) - time.sleep(responseDelay + 1) if count < 2: # remove the item from the list for j in range(len(multiPingList)): @@ -743,8 +1099,10 @@ def handleMultiPing(nodeID=0, deviceID=1): break priorVolcanoAlert = "" +priorEmergencyAlert = "" +priorWxAlert = "" def handleAlertBroadcast(deviceID=1): - global priorVolcanoAlert + global priorVolcanoAlert, priorEmergencyAlert, priorWxAlert alertUk = NO_ALERTS alertDe = NO_ALERTS alertFema = NO_ALERTS @@ -784,6 +1142,10 @@ def handleAlertBroadcast(deviceID=1): if emergencyAlertBrodcastEnabled: if NO_ALERTS not in femaAlert and ERROR_FETCHING_DATA not in femaAlert: + if femaAlert != priorEmergencyAlert: + priorEmergencyAlert = femaAlert + else: + return False if isinstance(emergencyAlertBroadcastCh, list): for channel in emergencyAlertBroadcastCh: send_message(femaAlert, int(channel), 0, deviceID) @@ -791,6 +1153,10 @@ def handleAlertBroadcast(deviceID=1): send_message(femaAlert, emergencyAlertBroadcastCh, 0, deviceID) return True if NO_ALERTS not in ukAlert: + if ukAlert != priorEmergencyAlert: + priorEmergencyAlert = ukAlert + else: + return False if isinstance(emergencyAlertBroadcastCh, list): for channel in emergencyAlertBroadcastCh: send_message(ukAlert, int(channel), 0, deviceID) @@ -799,27 +1165,29 @@ def handleAlertBroadcast(deviceID=1): return True if NO_ALERTS not in alertDe: + if deAlert != priorEmergencyAlert: + priorEmergencyAlert = deAlert + else: + return False if isinstance(emergencyAlertBroadcastCh, list): for channel in emergencyAlertBroadcastCh: - send_message(ukAlert, int(channel), 0, deviceID) + send_message(deAlert, int(channel), 0, deviceID) else: - send_message(ukAlert, emergencyAlertBroadcastCh, 0, deviceID) + send_message(deAlert, emergencyAlertBroadcastCh, 0, deviceID) return True - - # pause for traffic - time.sleep(5) if wxAlertBroadcastEnabled: if wxAlert: + if wxAlert != priorWxAlert: + priorWxAlert = wxAlert + else: + return False if isinstance(wxAlertBroadcastChannel, list): for channel in wxAlertBroadcastChannel: send_message(wxAlert, int(channel), 0, deviceID) else: send_message(wxAlert, wxAlertBroadcastChannel, 0, deviceID) return True - - # pause for traffic - time.sleep(5) if volcanoAlertBroadcastEnabled: volcanoAlert = get_volcano_usgs(latitudeValue, longitudeValue) @@ -840,12 +1208,13 @@ def onDisconnect(interface): interface.close() # Telemetry Functions -telemetryData = {} +localTelemetryData = {} def initialize_telemetryData(): - telemetryData[0] = {f'interface{i}': 0 for i in range(1, 10)} - telemetryData[0].update({f'lastAlert{i}': '' for i in range(1, 10)}) + global localTelemetryData + localTelemetryData[0] = {f'interface{i}': 0 for i in range(1, 10)} + localTelemetryData[0].update({f'lastAlert{i}': '' for i in range(1, 10)}) for i in range(1, 10): - telemetryData[i] = {'numPacketsTx': 0, 'numPacketsRx': 0, 'numOnlineNodes': 0, 'numPacketsTxErr': 0, 'numPacketsRxErr': 0, 'numTotalNodes': 0} + localTelemetryData[i] = {'numPacketsTx': 0, 'numPacketsRx': 0, 'numOnlineNodes': 0, 'numPacketsTxErr': 0, 'numPacketsRxErr': 0, 'numTotalNodes': 0} # indented to be called from the main loop initialize_telemetryData() @@ -864,26 +1233,60 @@ def getNodeFirmware(nodeID=0, nodeInt=1): return fwVer return -1 +def compileFavoriteList(getInterfaceIDs=True): + # build a list of favorite nodes to add to the device + fav_list = [] + + if getInterfaceIDs: + logger.debug(f"System:compileFavoriteList Collecting Nodes for use on roof client_base only") + # get the node IDs for each interface + for i in range(1, 10): + if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'): + myNodeNum = globals().get(f'myNodeNum{i}', 0) + if myNodeNum != 0: + object = {'nodeID': myNodeNum, 'deviceID': i} + fav_list.append(object) + logger.debug(f"System:compileFavoriteList Added NodeID {myNodeNum} favorite list") + + if not getInterfaceIDs: + logger.debug(f"System:compileFavoriteList Compiling Favorite Node List for use on bot to save DM keys only") + if (bbs_admin_list != [0] or favoriteNodeList != ['']) or bbs_link_whitelist != [0]: + logger.debug(f"System: Collecting Favorite Nodes to add to device(s)") + # loop through each interface and add the favorite nodes + for i in range(1, 10): + if globals().get(f'interface{i}') and globals().get(f'interface{i}_enabled'): + for fav in bbs_admin_list + favoriteNodeList + bbs_link_whitelist: + if fav != 0 and fav != '' and fav is not None: + object = {'nodeID': fav, 'deviceID': i} + # check object not already in the list + if object not in fav_list: + fav_list.append(object) + logger.debug(f"System:compileFavoriteList Favorite Node {fav}") + return fav_list + def displayNodeTelemetry(nodeID=0, rxNode=0, userRequested=False): interface = globals()[f'interface{rxNode}'] myNodeNum = globals().get(f'myNodeNum{rxNode}') - global telemetryData - + global localTelemetryData + # throttle the telemetry requests to prevent spamming the device if 1 <= rxNode <= 9: - if time.time() - telemetryData[0][f'interface{rxNode}'] < 600 and not userRequested: + if time.time() - localTelemetryData[0][f'interface{rxNode}'] < 600 and not userRequested: return -1 - telemetryData[0][f'interface{rxNode}'] = time.time() + localTelemetryData[0][f'interface{rxNode}'] = time.time() # some telemetry data is not available in python-meshtastic? # bring in values from the last telemetry dump for the node - numPacketsTx = telemetryData[rxNode]['numPacketsTx'] - numPacketsRx = telemetryData[rxNode]['numPacketsRx'] - numPacketsTxErr = telemetryData[rxNode]['numPacketsTxErr'] - numPacketsRxErr = telemetryData[rxNode]['numPacketsRxErr'] - numTotalNodes = telemetryData[rxNode]['numTotalNodes'] - totalOnlineNodes = telemetryData[rxNode]['numOnlineNodes'] - + numPacketsTx = localTelemetryData[rxNode].get('numPacketsTx', 0) + numPacketsRx = localTelemetryData[rxNode].get('numPacketsRx', 0) + numPacketsTxErr = localTelemetryData[rxNode].get('numPacketsTxErr', 0) + numPacketsRxErr = localTelemetryData[rxNode].get('numPacketsRxErr', 0) + numTotalNodes = localTelemetryData[rxNode].get('numTotalNodes', 0) + totalOnlineNodes = localTelemetryData[rxNode].get('numOnlineNodes', 0) + numRXDupes = localTelemetryData[rxNode].get('numRXDupes', 0) + numTxRelays = localTelemetryData[rxNode].get('numTxRelays', 0) + heapFreeBytes = localTelemetryData[rxNode].get('heapFreeBytes', 0) + heapTotalBytes = localTelemetryData[rxNode].get('heapTotalBytes', 0) # get the telemetry data for a node chutil = round(interface.nodes.get(decimal_to_hex(myNodeNum), {}).get("deviceMetrics", {}).get("channelUtilization", 0), 1) airUtilTx = round(interface.nodes.get(decimal_to_hex(myNodeNum), {}).get("deviceMetrics", {}).get("airUtilTx", 0), 1) @@ -921,111 +1324,571 @@ def displayNodeTelemetry(nodeID=0, rxNode=0, userRequested=False): if batteryLevel < 25: logger.warning(f"System: Low Battery Level: {batteryLevel}{emji} on Device: {rxNode}") + send_message(f"Low Battery Level: {batteryLevel}{emji} on Device: {rxNode}", {secure_channel}, 0, {secure_interface}) elif batteryLevel < 10: logger.critical(f"System: Critical Battery Level: {batteryLevel}{emji} on Device: {rxNode}") + + # if numRXDupes,numTxRelays,heapFreeBytes,heapTotalBytes are available loge them + # if numRXDupes != 0: + # dataResponse += f" RXDupes:{numRXDupes}" + # logger.debug(f"System: Device {rxNode} RX Dupes:{numRXDupes}") + # if numTxRelays != 0: + # dataResponse += f" TxRelays:{numTxRelays}" + # logger.debug(f"System: Device {rxNode} TX Relays:{numTxRelays}") + # if heapFreeBytes != 0 and heapTotalBytes != 0: + # logger.debug(f"System: Device {rxNode} Heap Memory Free:{heapFreeBytes} Total:{heapTotalBytes}") + #dataResponse += f" Heap:{heapFreeBytes}/{heapTotalBytes}" + return dataResponse positionMetadata = {} -def consumeMetadata(packet, rxNode=0): +meshLeaderboard = {} +def initializeMeshLeaderboard(): + global meshLeaderboard + # Leaderboard for tracking extreme metrics + meshLeaderboard = { + 'lowestBattery': {'nodeID': None, 'value': 101, 'timestamp': 0}, # ๐Ÿชซ + 'longestUptime': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿ•ฐ๏ธ + 'fastestSpeed': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿš“ + 'highestAltitude': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿš€ + 'coldestTemp': {'nodeID': None, 'value': 999, 'timestamp': 0}, # ๐Ÿฅถ + 'hottestTemp': {'nodeID': None, 'value': -999, 'timestamp': 0}, # ๐Ÿฅต + 'worstAirQuality': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿ’จ + 'mostMessages': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿ’ฌ + 'highestDBm': {'nodeID': None, 'value': -999, 'timestamp': 0}, # ๐Ÿ“ถ + 'weakestDBm': {'nodeID': None, 'value': 999, 'timestamp': 0}, # ๐Ÿ“ถ + 'mostReactions': {'nodeID': None, 'value': 0, 'timestamp': 0}, # โค๏ธ + 'mostPaxWiFi': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿ‘ฅ + 'mostPaxBLE': {'nodeID': None, 'value': 0, 'timestamp': 0}, # ๐Ÿ‘ฅ + 'adminPackets': [], # ๐Ÿšจ + 'tunnelPackets': [], # ๐Ÿšจ + 'audioPackets': [], # โ˜Ž๏ธ + 'simulatorPackets': [] # ๐Ÿค– + } + +initializeMeshLeaderboard() +def consumeMetadata(packet, rxNode=0, channel=-1): + global positionMetadata, localTelemetryData, meshLeaderboard + uptime = battery = temp = iaq = nodeID = 0 + deviceMetrics, envMetrics, localStats = {}, {}, {} + + # update telemetry data for the device try: - # keep records of recent telemetry data packet_type = '' if packet.get('decoded'): packet_type = packet['decoded']['portnum'] nodeID = packet['from'] - - # TELEMETRY packets - if packet_type == 'TELEMETRY_APP': - if debugMetadata: print(f"DEBUG TELEMETRY_APP: {packet}\n\n") - # get the telemetry data - telemetry_packet = packet['decoded']['telemetry'] - if telemetry_packet.get('deviceMetrics'): - deviceMetrics = telemetry_packet['deviceMetrics'] - if telemetry_packet.get('localStats'): - localStats = telemetry_packet['localStats'] - # Check if 'numPacketsTx' and 'numPacketsRx' exist and are not zero - if localStats.get('numPacketsTx') is not None and localStats.get('numPacketsRx') is not None and localStats['numPacketsTx'] != 0: - # Assign the values to the telemetry dictionary - keys = [ - 'numPacketsTx', 'numPacketsRx', 'numOnlineNodes', - 'numOfflineNodes', 'numPacketsTxErr', 'numPacketsRxErr', 'numTotalNodes'] - - for key in keys: - if localStats.get(key) is not None: - telemetryData[rxNode][key] = localStats.get(key) - # POSITION_APP packets - if packet_type == 'POSITION_APP': - if debugMetadata: print(f"DEBUG POSITION_APP: {packet}\n\n") - # get the position data - keys = ['altitude', 'groundSpeed', 'precisionBits'] - position_data = packet['decoded']['position'] + # if not a bot ID track it + if nodeID != globals().get(f'myNodeNum{rxNode}') and nodeID != 0: + # consider Meta for most messages leaderboard + node_message_count = meshLeaderboard.get('nodeMessageCounts', {}) + node_message_count[nodeID] = node_message_count.get(nodeID, 0) + 1 + meshLeaderboard['nodeMessageCounts'] = node_message_count + + if node_message_count[nodeID] > meshLeaderboard['mostMessages']['value']: + meshLeaderboard['mostMessages']['value'] = node_message_count[nodeID] + meshLeaderboard['mostMessages']['nodeID'] = nodeID + meshLeaderboard['mostMessages']['timestamp'] = time.time() + + # consider Meta for highest and weakest DBm + if packet.get('rxSnr') is not None: + dbm = packet['rxSnr'] + if dbm > meshLeaderboard['highestDBm']['value']: + meshLeaderboard['highestDBm'] = {'nodeID': nodeID, 'value': dbm, 'timestamp': time.time()} + if dbm < meshLeaderboard['weakestDBm']['value']: + meshLeaderboard['weakestDBm'] = {'nodeID': nodeID, 'value': dbm, 'timestamp': time.time()} + + except Exception as e: + logger.debug(f"System: Metadata decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # TELEMETRY packets + if packet_type == 'TELEMETRY_APP': + if debugMetadata and 'TELEMETRY_APP' not in metadataFilter: + print(f"DEBUG TELEMETRY_APP: {packet}\n\n") + telemetry_packet = packet['decoded']['telemetry'] + # Track lowest battery ๐Ÿชซ + if telemetry_packet.get('deviceMetrics'): + deviceMetrics = telemetry_packet['deviceMetrics'] + current_time = time.time() try: - if nodeID not in positionMetadata: - positionMetadata[nodeID] = {} - - for key in keys: - positionMetadata[nodeID][key] = position_data.get(key, 0) - - # if altitude is over 2000 send a log and message for high-flying nodes and not in highfly_ignoreList - if position_data.get('altitude', 0) > highfly_altitude and highfly_enabled and str(nodeID) not in highfly_ignoreList: - logger.info(f"System: High Altitude {position_data['altitude']}m on Device: {rxNode} NodeID: {nodeID}") - altFeet = round(position_data['altitude'] * 3.28084, 2) - send_message(f"High Altitude {altFeet}ft ({position_data['altitude']}m) on Device:{rxNode} Node:{get_name_from_number(nodeID,'short',rxNode)}", highfly_channel, 0, rxNode) - time.sleep(responseDelay) - - # Keep the positionMetadata dictionary at a maximum size of 20 - if len(positionMetadata) > 20: - # Remove the oldest entry - oldest_nodeID = next(iter(positionMetadata)) - del positionMetadata[oldest_nodeID] + if deviceMetrics.get('batteryLevel') is not None: + battery = float(deviceMetrics['batteryLevel']) + if battery > 0 and battery < float(meshLeaderboard['lowestBattery']['value']): + meshLeaderboard['lowestBattery'] = {'nodeID': nodeID, 'value': battery, 'timestamp': current_time} + if logMetaStats: + logger.info(f"System: ๐Ÿชซ New low battery record: {battery}% from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") except Exception as e: - logger.debug(f"System: POSITION_APP decode error: {e} packet {packet}") + logger.debug(f"System: TELEMETRY_APP batteryLevel error: Device: {rxNode} Channel: {channel} {e} packet {packet}") - # WAYPOINT_APP packets - if packet_type == 'WAYPOINT_APP': - if debugMetadata: print(f"DEBUG WAYPOINT_APP: {packet['decoded']['waypoint']}\n\n") - # get the waypoint data - waypoint_data = packet['decoded'] + # Track longest uptime ๐Ÿ•ฐ๏ธ + try: + # if not a bot ID track it + if nodeID != globals().get(f'myNodeNum{rxNode}') and nodeID != 0: + if deviceMetrics.get('uptimeSeconds') is not None: + uptime = float(deviceMetrics['uptimeSeconds']) + longest_uptime = float(meshLeaderboard['longestUptime']['value']) + if uptime > longest_uptime: + meshLeaderboard['longestUptime'] = {'nodeID': nodeID, 'value': uptime, 'timestamp': current_time} + except Exception as e: + logger.debug(f"System: TELEMETRY_APP uptimeSeconds error: Device: {rxNode} Channel: {channel} {e} packet {packet}") - # NEIGHBORINFO_APP - if packet_type == 'NEIGHBORINFO_APP': - if debugMetadata: print(f"DEBUG NEIGHBORINFO_APP: {packet}\n\n") - # get the neighbor info data + # Track environment metrics (temperature, air quality) + if telemetry_packet.get('environmentMetrics'): + envMetrics = telemetry_packet['environmentMetrics'] + current_time = time.time() + try: + if envMetrics.get('temperature') is not None: + temp = float(envMetrics['temperature']) + if temp < float(meshLeaderboard['coldestTemp']['value']): + meshLeaderboard['coldestTemp'] = {'nodeID': nodeID, 'value': temp, 'timestamp': current_time} + if logMetaStats: + logger.info(f"System: ๐Ÿฅถ New coldest temp record: {temp}ยฐC from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + if temp > float(meshLeaderboard['hottestTemp']['value']): + meshLeaderboard['hottestTemp'] = {'nodeID': nodeID, 'value': temp, 'timestamp': current_time} + if logMetaStats: + logger.info(f"System: ๐Ÿฅต New hottest temp record: {temp}ยฐC from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: TELEMETRY_APP temperature error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + try: + # Track worst air quality ๐Ÿ’จ (IAQ - higher is worse) + if envMetrics.get('iaq') is not None: + iaq = float(envMetrics['iaq']) + if iaq > float(meshLeaderboard['worstAirQuality']['value']): + meshLeaderboard['worstAirQuality'] = {'nodeID': nodeID, 'value': iaq, 'timestamp': current_time} + if logMetaStats: + logger.info(f"System: ๐Ÿ’จ New worst air quality record: IAQ {iaq} from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: TELEMETRY_APP iaq error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # Update localStats in telemetryData + if telemetry_packet.get('localStats'): + localStats = telemetry_packet['localStats'] + try: + # Only store keys where value is not 0 + filtered_stats = {k: v for k, v in localStats.items() if v != 0} + localTelemetryData[rxNode].update(filtered_stats) + except Exception as e: + logger.debug(f"System: TELEMETRY_APP localStats error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + #POSITION_APP packets + if packet_type == 'POSITION_APP': + try: + if debugMetadata and 'POSITION_APP' not in metadataFilter: + print(f"DEBUG POSITION_APP: {packet}\n\n") + position_stats_keys = ['altitude', 'groundSpeed', 'precisionBits'] + position_data = packet['decoded']['position'] + if nodeID not in positionMetadata: + positionMetadata[nodeID] = {} + for key in position_stats_keys: + positionMetadata[nodeID][key] = position_data.get(key, 0) + # Track fastest speed ๐Ÿš“ + if position_data.get('groundSpeed') is not None: + if use_metric: + speed = position_data['groundSpeed'] + else: + speed = round(position_data['groundSpeed'] * 1.60934, 1) # Convert mph to km/h + if speed > meshLeaderboard['fastestSpeed']['value']: + meshLeaderboard['fastestSpeed'] = {'nodeID': nodeID, 'value': speed, 'timestamp': time.time()} + if logMetaStats: + logger.info(f"System: ๐Ÿš“ New speed record: {speed} km/h from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + # Track highest altitude ๐Ÿš€ (also log if over highfly_altitude threshold) + if position_data.get('altitude') is not None: + altitude = position_data['altitude'] + if altitude > meshLeaderboard['highestAltitude']['value']: + meshLeaderboard['highestAltitude'] = {'nodeID': nodeID, 'value': altitude, 'timestamp': time.time()} + if logMetaStats: + logger.info(f"System: ๐Ÿš€ New altitude record: {altitude}m from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + # if altitude is over highfly_altitude send a log and message for high-flying nodes and not in highfly_ignoreList + if position_data.get('altitude', 0) > highfly_altitude and highfly_enabled and str(nodeID) not in highfly_ignoreList and not isNodeBanned(nodeID): + logger.info(f"System: High Altitude {position_data['altitude']}m on Device: {rxNode} Channel: {channel} NodeID:{nodeID} Lat:{position_data.get('latitude', 0)} Lon:{position_data.get('longitude', 0)}") + altFeet = round(position_data['altitude'] * 3.28084, 2) + msg = f"๐Ÿš€ High Altitude Detected! NodeID:{nodeID} Alt:{altFeet:,.0f}ft/{position_data['altitude']:,.0f}m" + + # throttle sending alerts for the same node more than once every 30 minutes + last_alert_time = positionMetadata[nodeID].get('lastHighFlyAlert', 0) + current_time = time.time() + if current_time - last_alert_time < 1800: + return False # less than 30 minutes since last alert + positionMetadata[nodeID]['lastHighFlyAlert'] = current_time + + if highfly_check_openskynetwork: + # check get_openskynetwork to see if the node is an aircraft + if 'latitude' in position_data and 'longitude' in position_data: + flight_info = get_openskynetwork(position_data.get('latitude', 0), position_data.get('longitude', 0)) + # Only show plane if within altitude + if ( + flight_info + and NO_ALERTS not in flight_info + and ERROR_FETCHING_DATA not in flight_info + and isinstance(flight_info, dict) + and 'altitude' in flight_info + ): + plane_alt = flight_info['altitude'] + node_alt = position_data.get('altitude', 0) + if abs(node_alt - plane_alt) <= 900: # within 900m + msg += f"\nโœˆ๏ธDetected near:\n{flight_info}" + send_message(msg, highfly_channel, 0, highfly_interface) + + # Keep the positionMetadata dictionary at a maximum size + if len(positionMetadata) > MAX_SEEN_NODES: + # Remove the oldest entry + oldest_nodeID = next(iter(positionMetadata)) + del positionMetadata[oldest_nodeID] + # add a packet count to the positionMetadata for the node + if 'packetCount' in positionMetadata[nodeID]: + positionMetadata[nodeID]['packetCount'] += 1 + else: + positionMetadata[nodeID]['packetCount'] = 1 + except Exception as e: + logger.debug(f"System: POSITION_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # WAYPOINT_APP packets + if packet_type == 'WAYPOINT_APP': + try: + if debugMetadata and 'WAYPOINT_APP' not in metadataFilter: + print(f"DEBUG WAYPOINT_APP: {packet}\n\n") + waypoint_data = packet['decoded']['waypoint'] + id = waypoint_data.get('id', 0) + latitudeI = waypoint_data.get('latitudeI', 0) + longitudeI = waypoint_data.get('longitudeI', 0) + expire = waypoint_data.get('expire', 0) + if expire == 1: + expire = "Now" + elif expire == 0: + expire = "Never" + else: + expire = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expire)) + description = waypoint_data.get('description', '') + name = waypoint_data.get('name', '') + if logMetaStats: + logger.info(f"System: Waypoint from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ID:{id} Lat:{latitudeI/1e7} Lon:{longitudeI/1e7} Expire:{expire} Name:{name} Desc:{description}") + except Exception as e: + logger.debug(f"System: WAYPOINT_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # NEIGHBORINFO_APP + if packet_type == 'NEIGHBORINFO_APP': + try: + if debugMetadata and 'NEIGHBORINFO_APP' not in metadataFilter: + print(f"DEBUG NEIGHBORINFO_APP: {packet}\n\n") neighbor_data = packet['decoded'] - - # TRACEROUTE_APP - if packet_type == 'TRACEROUTE_APP': - if debugMetadata: print(f"DEBUG TRACEROUTE_APP: {packet}\n\n") - # get the traceroute data + neighbor_list = neighbor_data.get('neighbors', []) + if logMetaStats: + logger.info(f"System: Neighbor Info from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Neighbors:{len(neighbor_list)}") + except Exception as e: + logger.debug(f"System: NEIGHBORINFO_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # TRACEROUTE_APP + if packet_type == 'TRACEROUTE_APP': + try: + if debugMetadata and 'TRACEROUTE_APP' not in metadataFilter: + print(f"DEBUG TRACEROUTE_APP: {packet}\n\n") traceroute_data = packet['decoded'] + # (add any logic here if needed) + except Exception as e: + logger.debug(f"System: TRACEROUTE_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") - # DETECTION_SENSOR_APP - if packet_type == 'DETECTION_SENSOR_APP': - if debugMetadata: print(f"DEBUG DETECTION_SENSOR_APP: {packet}\n\n") - # get the detection sensor data + # DETECTION_SENSOR_APP + if packet_type == 'DETECTION_SENSOR_APP': + try: + if debugMetadata and 'DETECTION_SENSOR_APP' not in metadataFilter: + print(f"DEBUG DETECTION_SENSOR_APP: {packet}\n\n") detection_data = packet['decoded'] + detction_text = detection_data.get('text', '') + if detction_text != '': + if logMetaStats: + logger.info(f"System: Detection Sensor Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Text:{detction_text}") + if detctionSensorAlert: + send_message(f"๐ŸšจDetection Sensor from Device: {rxNode} Channel: {channel} NodeID:{get_name_from_number(nodeID,'long',rxNode)} Alert:{detction_text}", secure_channel, 0, secure_interface) + except Exception as e: + logger.debug(f"System: DETECTION_SENSOR_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") - # PAXCOUNTER_APP - if packet_type == 'PAXCOUNTER_APP': - if debugMetadata: print(f"DEBUG PAXCOUNTER_APP: {packet}\n\n") - # get the paxcounter data - paxcounter_data = packet['decoded'] - - # REMOTE_HARDWARE_APP - if packet_type == 'REMOTE_HARDWARE_APP': - if debugMetadata: print(f"DEBUG REMOTE_HARDWARE_APP: {packet}\n\n") - # get the remote hardware data + # PAXCOUNTER_APP + if packet_type == 'PAXCOUNTER_APP': + try: + if debugMetadata and 'PAXCOUNTER_APP' not in metadataFilter: + print(f"DEBUG PAXCOUNTER_APP: {packet}\n\n") + paxcounter_data = packet['decoded']['paxcounter'] + wifi_count = paxcounter_data.get('wifi', 0) + ble_count = paxcounter_data.get('ble', 0) + uptime = paxcounter_data.get('uptime', 0) + current_time = time.time() + # Track most WiFi + if wifi_count > meshLeaderboard['mostPaxWiFi']['value']: + meshLeaderboard['mostPaxWiFi'] = {'nodeID': nodeID, 'value': wifi_count, 'timestamp': current_time} + # Track most BLE + if ble_count > meshLeaderboard['mostPaxBLE']['value']: + meshLeaderboard['mostPaxBLE'] = {'nodeID': nodeID, 'value': ble_count, 'timestamp': current_time} + if logMetaStats: + logger.info(f"System: Paxcounter Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} WiFi:{wifi_count} BLE:{ble_count} Uptime:{getPrettyTime(uptime)}") + except Exception as e: + logger.debug(f"System: PAXCOUNTER_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # REMOTE_HARDWARE_APP + if packet_type == 'REMOTE_HARDWARE_APP': + try: + if debugMetadata and 'REMOTE_HARDWARE_APP' not in metadataFilter: + print(f"DEBUG REMOTE_HARDWARE_APP: {packet}\n\n") remote_hardware_data = packet['decoded'] - except KeyError as e: - logger.critical(f"System: Error consuming metadata: {e} Device:{rxNode}") - logger.debug(f"System: Error Packet = {packet}") + hardware_info = remote_hardware_data.get('hardware_info', '') + if logMetaStats: + logger.info(f"System: Remote Hardware Data from Device: {rxNode} Channel: {channel} NodeID:{nodeID} Info:{hardware_info}") + except Exception as e: + logger.debug(f"System: REMOTE_HARDWARE_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # ADMIN_APP - Track admin packets ๐Ÿšจ + if packet_type == 'ADMIN_APP': + try: + if debugMetadata and 'ADMIN_APP' not in metadataFilter: + print(f"DEBUG ADMIN_APP: {packet}\n\n") + # if not a bot ID track it + if nodeID != globals().get(f'myNodeNum{rxNode}') and nodeID != 0: + packet_info = {'nodeID': nodeID, 'timestamp': time.time(), 'device': rxNode, 'channel': channel} + # if not a bot ID track it + if nodeID != globals().get(f'myNodeNum{rxNode}') and nodeID != 0: + meshLeaderboard['adminPackets'].append(packet_info) + if len(meshLeaderboard['adminPackets']) > 10: + meshLeaderboard['adminPackets'].pop(0) + if logMetaStats: + logger.info(f"System: ๐Ÿšจ Admin packet detected from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: ADMIN_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # IP_TUNNEL_APP - Track tunneling packets ๐Ÿšจ + if packet_type == 'IP_TUNNEL_APP': + try: + if debugMetadata and 'IP_TUNNEL_APP' not in metadataFilter: + print(f"DEBUG IP_TUNNEL_APP: {packet}\n\n") + packet_info = {'nodeID': nodeID, 'timestamp': time.time(), 'device': rxNode, 'channel': channel} + meshLeaderboard['tunnelPackets'].append(packet_info) + if len(meshLeaderboard['tunnelPackets']) > 10: + meshLeaderboard['tunnelPackets'].pop(0) + if logMetaStats: + logger.info(f"System: ๐Ÿšจ IP Tunnel packet detected from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: IP_TUNNEL_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # SERIAL_APP + + # STORE_FOWARD_APP + + # RANGE_TEST_APP + + # COMPRESSED_TEXT_APP + + # ATTAK_APP + + # SERIAL_APP + + # NODE_DB_APP + + # RTTTL_APP + + # STORE_AND_FORWARD_APP + + # DEBUG_APP + + # RANGEREPORT_APP + + # CENSUS_APP + + # AUDIO_APP - Track audio/voice packets โ˜Ž๏ธ + if packet_type == 'AUDIO_APP': + try: + if debugMetadata and 'AUDIO_APP' not in metadataFilter: + print(f"DEBUG AUDIO_APP: {packet}\n\n") + packet_info = {'nodeID': nodeID, 'timestamp': time.time(), 'device': rxNode, 'channel': channel} + meshLeaderboard['audioPackets'].append(packet_info) + if len(meshLeaderboard['audioPackets']) > 10: + meshLeaderboard['audioPackets'].pop(0) + if logMetaStats: + logger.info(f"System: โ˜Ž๏ธ Audio packet detected from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: AUDIO_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + # SIMULATOR_APP - Track simulator packets ๐Ÿค– + if packet_type == 'SIMULATOR_APP': + try: + if debugMetadata and 'SIMULATOR_APP' not in metadataFilter: + print(f"DEBUG SIMULATOR_APP: {packet}\n\n") + packet_info = {'nodeID': nodeID, 'timestamp': time.time(), 'device': rxNode, 'channel': channel} + # if not a bot ID track it + if nodeID != globals().get(f'myNodeNum{rxNode}') and nodeID != 0: + meshLeaderboard['simulatorPackets'].append(packet_info) + if len(meshLeaderboard['simulatorPackets']) > 10: + meshLeaderboard['simulatorPackets'].pop(0) + if logMetaStats: + logger.info(f"System: ๐Ÿค– Simulator packet detected from Device: {rxNode} Channel: {channel} NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', rxNode)}") + except Exception as e: + logger.debug(f"System: SIMULATOR_APP decode error: Device: {rxNode} Channel: {channel} {e} packet {packet}") + + return True + +def noisyTelemetryCheck(): + global positionMetadata + if len(positionMetadata) == 0: + return + # sort the positionMetadata by packetCount + sorted_positionMetadata = dict(sorted(positionMetadata.items(), key=lambda item: item[1].get('packetCount', 0), reverse=True)) + top_three = list(sorted_positionMetadata.items())[:3] + for nodeID, data in top_three: + if data.get('packetCount', 0) > noisyTelemetryLimit: + logger.warning(f"System: Noisy Telemetry Detected from NodeID:{nodeID} ShortName:{get_name_from_number(nodeID, 'short', 1)} Packets:{data.get('packetCount', 0)}") + # reset the packet count for the node + positionMetadata[nodeID]['packetCount'] = 0 + +def saveLeaderboard(): + # save the meshLeaderboard to a pickle file + global meshLeaderboard + try: + with open('data/leaderboard.pkl', 'wb') as f: + pickle.dump(meshLeaderboard, f) + if logMetaStats: + logger.debug("System: Mesh Leaderboard saved to leaderboard.pkl") + except Exception as e: + logger.warning(f"System: Error saving Mesh Leaderboard: {e}") + +def loadLeaderboard(): + global meshLeaderboard + try: + defaults = {} + initializeMeshLeaderboard() + with open('data/leaderboard.pkl', 'rb') as f: + meshLeaderboard = pickle.load(f) + defaults.update(meshLeaderboard) # loaded values overwrite defaults + meshLeaderboard = defaults + if logMetaStats: + logger.debug("System: Mesh Leaderboard loaded from leaderboard.pkl") + except FileNotFoundError: + if logMetaStats: + logger.debug("System: No existing Mesh Leaderboard found, starting fresh") + initializeMeshLeaderboard() + except Exception as e: + logger.warning(f"System: Error loading Mesh Leaderboard: {e}") + initializeMeshLeaderboard() + +def get_mesh_leaderboard(msg, fromID, deviceID): + """Get formatted leaderboard of extreme mesh metrics""" + global meshLeaderboard + result = "๐Ÿ“ŠLeaderboard๐Ÿ“Š\n" + + if "reset" in msg.lower() and str(fromID) in bbs_admin_list: + initializeMeshLeaderboard() + return "โœ… Leaderboard has been reset." + + # Lowest battery + if meshLeaderboard['lowestBattery']['nodeID']: + nodeID = meshLeaderboard['lowestBattery']['nodeID'] + value = round(meshLeaderboard['lowestBattery']['value'], 1) + result += f"๐Ÿชซ Low Battery: {value}% {get_name_from_number(nodeID, 'short', 1)}\n" + + # Longest uptime + if meshLeaderboard['longestUptime']['nodeID']: + nodeID = meshLeaderboard['longestUptime']['nodeID'] + value = meshLeaderboard['longestUptime']['value'] + result += f"๐Ÿ•ฐ๏ธ Uptime: {getPrettyTime(value)} {get_name_from_number(nodeID, 'short', 1)}\n" + + # Fastest speed + if meshLeaderboard['fastestSpeed']['nodeID']: + nodeID = meshLeaderboard['fastestSpeed']['nodeID'] + value_kmh = round(meshLeaderboard['fastestSpeed']['value'], 1) + value_mph = round(value_kmh / 1.60934, 1) + if use_metric: + result += f"๐Ÿš“ Speed: {value_kmh} km/h {get_name_from_number(nodeID, 'short', 1)}\n" + else: + result += f"๐Ÿš“ Speed: {value_mph} mph {get_name_from_number(nodeID, 'short', 1)}\n" + + # Highest altitude + if meshLeaderboard['highestAltitude']['nodeID']: + nodeID = meshLeaderboard['highestAltitude']['nodeID'] + value_m = meshLeaderboard['highestAltitude']['value'] + value_ft = round(value_m * 3.28084, 0) + if use_metric: + result += f"๐Ÿš€ Altitude: {int(round(value_m, 0))}m {get_name_from_number(nodeID, 'short', 1)}\n" + else: + result += f"๐Ÿš€ Altitude: {int(value_ft)}ft {get_name_from_number(nodeID, 'short', 1)}\n" + + # Coldest temperature + if meshLeaderboard['coldestTemp']['nodeID']: + nodeID = meshLeaderboard['coldestTemp']['nodeID'] + value_c = round(meshLeaderboard['coldestTemp']['value'], 1) + value_f = round((value_c * 9/5) + 32, 1) + if use_metric: + result += f"๐Ÿฅถ Coldest: {value_c}ยฐC {get_name_from_number(nodeID, 'short', 1)}\n" + else: + result += f"๐Ÿฅถ Coldest: {value_f}ยฐF {get_name_from_number(nodeID, 'short', 1)}\n" + + # Hottest temperature + if meshLeaderboard['hottestTemp']['nodeID']: + nodeID = meshLeaderboard['hottestTemp']['nodeID'] + value_c = round(meshLeaderboard['hottestTemp']['value'], 1) + value_f = round((value_c * 9/5) + 32, 1) + if use_metric: + result += f"๐Ÿฅต Hottest: {value_c}ยฐC {get_name_from_number(nodeID, 'short', 1)}\n" + else: + result += f"๐Ÿฅต Hottest: {value_f}ยฐF {get_name_from_number(nodeID, 'short', 1)}\n" + + # Worst air quality + if meshLeaderboard['worstAirQuality']['nodeID']: + nodeID = meshLeaderboard['worstAirQuality']['nodeID'] + value = round(meshLeaderboard['worstAirQuality']['value'], 1) + result += f"๐Ÿ’จ Worst IAQ: {value} {get_name_from_number(nodeID, 'short', 1)}\n" + + # Weakest RF + if meshLeaderboard['weakestDBm']['nodeID'] is not None: + nodeID = meshLeaderboard['weakestDBm']['nodeID'] + value = meshLeaderboard['weakestDBm']['value'] + result += f"๐Ÿ“ถ Weakest RF: {value} dBm {get_name_from_number(nodeID, 'short', 1)}\n" + + # Best RF + if meshLeaderboard['highestDBm']['nodeID'] is not None: + nodeID = meshLeaderboard['highestDBm']['nodeID'] + value = meshLeaderboard['highestDBm']['value'] + result += f"๐Ÿ“ถ Best RF: {value} dBm {get_name_from_number(nodeID, 'short', 1)}\n" + + # Most Telemetry Messages + if 'nodeMessageCounts' in meshLeaderboard and meshLeaderboard['mostMessages']['nodeID'] is not None: + nodeID = meshLeaderboard['mostMessages']['nodeID'] + value = meshLeaderboard['mostMessages']['value'] + result += f"๐Ÿ’ฌ Most Telemetry: {value} {get_name_from_number(nodeID, 'short', 1)}\n" + + # Most WiFi devices seen + if meshLeaderboard.get('mostPaxWiFi', {}).get('nodeID'): + nodeID = meshLeaderboard['mostPaxWiFi']['nodeID'] + value = meshLeaderboard['mostPaxWiFi']['value'] + result += f"๐Ÿ“ถ PAX Wifi: {value} {get_name_from_number(nodeID, 'short', 1)}\n" + # Most BLE devices seen + if meshLeaderboard.get('mostPaxBLE', {}).get('nodeID'): + nodeID = meshLeaderboard['mostPaxBLE']['nodeID'] + value = meshLeaderboard['mostPaxBLE']['value'] + result += f"๐Ÿ“ฒ PAX BLE: {value} {get_name_from_number(nodeID, 'short', 1)}\n" + + # Special packet detections + if len(meshLeaderboard['adminPackets']) > 0: + result += f"๐Ÿšจ Admin packets: {len(meshLeaderboard['adminPackets'])}\n" + + if len(meshLeaderboard['tunnelPackets']) > 0: + result += f"๐Ÿšจ Tunnel packets: {len(meshLeaderboard['tunnelPackets'])}\n" + + if len(meshLeaderboard['audioPackets']) > 0: + result += f"โ˜Ž๏ธ Audio packets: {len(meshLeaderboard['audioPackets'])}\n" + + if len(meshLeaderboard['simulatorPackets']) > 0: + result += f"๐Ÿค– Simulator packets: {len(meshLeaderboard['simulatorPackets'])}\n" + + result = result.strip() + + if result == "๐Ÿ“ŠLeaderboard๐Ÿ“Š\n": + result += "No records yet! Keep meshing! ๐Ÿ“ก" + + return result def get_sysinfo(nodeID=0, deviceID=1): # Get the system telemetry data for return on the sysinfo command sysinfo = '' stats = str(displayNodeTelemetry(nodeID, deviceID, userRequested=True)) + " ๐Ÿค–๐Ÿ‘€" + str(len(seenNodes)) - if "numPacketsRx:0" in stats or stats == -1: + if "numPacketsTx:0" in stats or stats == -1: return "Gathering Telemetry try again laterโณ" # replace Telemetry with Int in string stats = stats.replace("Telemetry", "Int") @@ -1053,24 +1916,12 @@ async def handleSignalWatcher(): if type(sigWatchBroadcastCh) is list: for ch in sigWatchBroadcastCh: if antiSpam and ch != publicChannel: - send_message(msg, int(ch), 0, 1) - time.sleep(responseDelay) - if multiple_interface: - for i in range(2, 10): - if globals().get(f'interface{i}_enabled'): - send_message(msg, int(ch), 0, i) - time.sleep(responseDelay) + send_message(msg, int(ch), 0, sigWatchBroadcastInterface) else: logger.warning(f"System: antiSpam prevented Alert from Hamlib {msg}") else: if antiSpam and sigWatchBroadcastCh != publicChannel: - send_message(msg, int(sigWatchBroadcastCh), 0, 1) - time.sleep(responseDelay) - if multiple_interface: - for i in range(2, 10): - if globals().get(f'interface{i}_enabled'): - send_message(msg, int(sigWatchBroadcastCh), 0, i) - time.sleep(responseDelay) + send_message(msg, int(sigWatchBroadcastCh), 0, sigWatchBroadcastInterface) else: logger.warning(f"System: antiSpam prevented Alert from Hamlib {msg}") @@ -1093,23 +1944,19 @@ async def handleFileWatcher(): for ch in file_monitor_broadcastCh: if antiSpam and int(ch) != publicChannel: send_message(msg, int(ch), 0, 1) - time.sleep(responseDelay) if multiple_interface: for i in range(2, 10): if globals().get(f'interface{i}_enabled'): send_message(msg, int(ch), 0, i) - time.sleep(responseDelay) else: logger.warning(f"System: antiSpam prevented Alert from FileWatcher") else: if antiSpam and file_monitor_broadcastCh != publicChannel: send_message(msg, int(file_monitor_broadcastCh), 0, 1) - time.sleep(responseDelay) if multiple_interface: for i in range(2, 10): if globals().get(f'interface{i}_enabled'): send_message(msg, int(file_monitor_broadcastCh), 0, i) - time.sleep(responseDelay) else: logger.warning(f"System: antiSpam prevented Alert from FileWatcher") @@ -1167,66 +2014,99 @@ handleSentinel_spotted = [] handleSentinel_loop = 0 async def handleSentinel(deviceID): global handleSentinel_spotted, handleSentinel_loop - detectedNearby = "" + detectedNearby = None resolution = "unknown" - closest_nodes = get_closest_nodes(deviceID) - closest_node = closest_nodes[0]['id'] if closest_nodes != ERROR_FETCHING_DATA and closest_nodes else None - closest_distance = closest_nodes[0]['distance'] if closest_nodes != ERROR_FETCHING_DATA and closest_nodes else None - # check if the handleSentinel_spotted list contains the closest node already - if closest_node in [i['id'] for i in handleSentinel_spotted]: - # check if the distance is closer than the last time, if not just return - for i in range(len(handleSentinel_spotted)): - if handleSentinel_spotted[i]['id'] == closest_node and closest_distance is not None and closest_distance < handleSentinel_spotted[i]['distance']: - handleSentinel_spotted[i]['distance'] = closest_distance - break - else: - return - - if closest_nodes != ERROR_FETCHING_DATA and closest_nodes: - if closest_nodes[0]['id'] is not None: - detectedNearby = get_name_from_number(closest_node, 'long', deviceID) - detectedNearby += ", " + get_name_from_number(closest_nodes[0]['id'], 'short', deviceID) - detectedNearby += ", " + str(closest_nodes[0]['id']) - detectedNearby += ", " + decimal_to_hex(closest_nodes[0]['id']) - detectedNearby += f" at {closest_distance}m" + closest_nodes = await get_closest_nodes(deviceID, returnCount=10) + #logger.debug(f"handleSentinel: closest_nodes={closest_nodes}") - if handleSentinel_loop >= sentry_holdoff and detectedNearby not in ["", None]: - if closest_nodes and positionMetadata and closest_nodes[0]['id'] in positionMetadata: - metadata = positionMetadata[closest_nodes[0]['id']] - if metadata.get('precisionBits') is not None: - resolution = metadata.get('precisionBits') + if not closest_nodes or closest_nodes == ERROR_FETCHING_DATA: + return - logger.warning(f"System: {detectedNearby} is close to your location on Interface{deviceID} Accuracy is {resolution}bits") - for i in range(1, 10): - if globals().get(f'interface{i}_enabled'): - send_message(f"Sentry{deviceID}: {detectedNearby}", secure_channel, 0, i) - time.sleep(responseDelay + 1) - if enableSMTP and email_sentry_alerts: - for email in sysopEmails: - send_email(email, f"Sentry{deviceID}: {detectedNearby}") - handleSentinel_loop = 0 - handleSentinel_spotted.append({'id': closest_node, 'distance': closest_distance}) - else: + # Find any watched node inside or outside the zone + for node in closest_nodes: + node_id = node['id'] + distance = node['distance'] + + if str(node_id) in sentryIgnoreList: + return + # Message conditions + if distance >= sentry_radius and str(node_id) and str(node_id) in sentryWatchList: + # Outside zone + detectedNearby = f"{get_name_from_number(node_id, 'long', deviceID)}, {get_name_from_number(node_id, 'short', deviceID)}, {node_id}, {decimal_to_hex(node_id)} at {distance}m (OUTSIDE ZONE)" + elif distance <= sentry_radius and str(node_id) not in sentryWatchList: + # Inside the zone + detectedNearby = f"{get_name_from_number(node_id, 'long', deviceID)}, {get_name_from_number(node_id, 'short', deviceID)}, {node_id}, {decimal_to_hex(node_id)} at {distance}m (INSIDE ZONE)" + + #logger.debug(f"handleSentinel: loop={handleSentinel_loop}/{sentry_holdoff}, detectedNearby={detectedNearby} closest_nodes={closest_nodes}") + if detectedNearby: handleSentinel_loop += 1 + #logger.debug(f"handleSentinel: detectedNearby={detectedNearby}, loop={handleSentinel_loop}/{sentry_holdoff}") + if handleSentinel_loop >= sentry_holdoff: + # Get resolution if available + if positionMetadata and node_id in positionMetadata: + metadata = positionMetadata[node_id] + if metadata.get('precisionBits') is not None: + resolution = metadata.get('precisionBits') + # Send message alert + logger.warning(f"System: {detectedNearby} on Interface{deviceID} Accuracy is {resolution}bits") + send_message(f"Sentry{deviceID}: {detectedNearby}", secure_channel, 0, secure_interface) + + # Send email alerts + if enableSMTP and email_sentry_alerts: + for email in sysopEmails: + send_email(email, f"Sentry{deviceID}: {detectedNearby}") + + # Execute external script alerts + if cmdShellSentryAlerts and distance <= sentry_radius: + # inside zone + call_external_script('', script=sentryAlertNear) + logger.info(f"System: Sentry Script Alert {sentryAlertNear} for NodeID:{node_id} on Interface{deviceID}") + elif cmdShellSentryAlerts and distance >= sentry_radius: + # outside zone + call_external_script('', script=sentryAlertFar) + logger.info(f"System: Sentry Script Alert {sentryAlertFar} for NodeID:{node_id} on Interface{deviceID}") + + handleSentinel_loop = 0 # Loop reset + else: + handleSentinel_loop = 0 # Reset if nothing detected + +async def process_vox_queue(): + # process the voxMsgQueue + global voxMsgQueue + items_to_process = voxMsgQueue[:] + voxMsgQueue.clear() + if len(items_to_process) > 0: + logger.debug(f"System: Processing {len(items_to_process)} items in voxMsgQueue") + for item in items_to_process: + message = item + for channel in sigWatchBroadcastCh: + if antiSpam and int(channel) != publicChannel: + send_message(message, int(channel), 0, sigWatchBroadcastInterface) async def watchdog(): - global telemetryData, retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9 + global localTelemetryData, retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9 + logger.debug("System: Watchdog started") while True: await asyncio.sleep(20) + # perform memory cleanup every 10 minutes + if datetime.now().minute % 10 == 0: + cleanup_memory() + # check all interfaces for i in range(1, 10): interface = globals().get(f'interface{i}') retry_int = globals().get(f'retry_int{i}') - if interface is not None and not retry_int and globals().get(f'interface{i}_enabled'): + int_enabled = globals().get(f'interface{i}_enabled') + if interface is not None and not retry_int and int_enabled: try: firmware = getNodeFirmware(0, i) except Exception as e: logger.error(f"System: communicating with interface{i}, trying to reconnect: {e}") globals()[f'retry_int{i}'] = True - if not globals()[f'retry_int{i}']: + if not retry_int and int_enabled: if sentry_enabled: await handleSentinel(i) @@ -1236,15 +2116,28 @@ async def watchdog(): handleAlertBroadcast(i) intData = displayNodeTelemetry(0, i) - if intData != -1 and telemetryData[0][f'lastAlert{i}'] != intData: + if intData != -1 and localTelemetryData[0][f'lastAlert{i}'] != intData: logger.debug(intData + f" Firmware:{firmware}") - telemetryData[0][f'lastAlert{i}'] = intData + localTelemetryData[0][f'lastAlert{i}'] = intData - if globals()[f'retry_int{i}'] and globals()[f'interface{i}_enabled']: + if retry_int and int_enabled: try: await retry_interface(i) except Exception as e: logger.error(f"System: retrying interface{i}: {e}") + + # check for noisy telemetry + if noisyNodeLogging: + noisyTelemetryCheck() + + # vox queue processing + if voxDetectionEnabled: + await process_vox_queue() + + # check the load_bbsdm flag to reload the BBS messages from disk + if bbs_enabled and bbsAPI_enabled: + load_bbsdm() + load_bbsdb() def exit_handler(): # Close the interface and save the BBS messages @@ -1263,6 +2156,8 @@ def exit_handler(): save_bbsdb() save_bbsdm() logger.debug(f"System: BBS Messages Saved") + if logMetaStats: + saveLeaderboard() logger.debug(f"System: Exiting") asyncLoop.stop() asyncLoop.close() diff --git a/modules/udp.py b/modules/udp.py new file mode 100644 index 0000000..bb66553 --- /dev/null +++ b/modules/udp.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# UDP Interface Listener +# credit to pdxlocations for all of this core work https://github.com/pdxlocations/ +# depends on: pip install meshtastic protobuf zeroconf pubsub +# 2025 Kelly Keeton K7MHI +from pubsub import pub +from meshtastic.protobuf import mesh_pb2, portnums_pb2 +from mudp import UDPPacketStream, node, conn, send_text_message, send_nodeinfo, send_device_telemetry, send_position, send_environment_metrics, send_power_metrics, send_waypoint, send_data +from mudp.encryption import generate_hash +import time +from zeroconf import Zeroconf, ServiceBrowser +import socket + +MCAST_GRP, MCAST_PORT, CHANNEL_ID, KEY = "224.0.0.69", 4403, "LongFast", "1PG7OiApB1nwvP+rz05pAQ==" +PUBLIC_CHANNEL_IDS = ["LongFast", "ShortSlow", "MediumFast", "MediumSlow", "ShortFast", "ShortTurbo"] +mudpEnabled, mudpInterface = True, None +messages = [] + +class ZeroconfListner: + def add_service(self, zeroconf, type, name): + info = zeroconf.get_service_info(type, name) + if info: + txt = info.properties + ip = None + if info.addresses: + ip = socket.inet_ntoa(info.addresses[0]) + print(f"Found Meshtastic node: id={txt.get(b'id', b'').decode()} shortname={txt.get(b'shortname', b'').decode()} longname={txt.get(b'longname', b'').decode()} ip={ip}") + + def update_service(self, zeroconf, type, name): + # This method is required by zeroconf, but you can leave it empty if you don't need updates. + pass + +def initalize_mudp(): + global mudpInterface + if mudpEnabled and mudpInterface is None: + mudpInterface = UDPPacketStream(MCAST_GRP, MCAST_PORT, key=KEY) + print(f"MUDP Interface initialized with multicast group", MCAST_GRP, "port", MCAST_PORT) + node.node_id, node.long_name, node.short_name = "!deadbeef", "UDP Test", "UDP" + node.channel, node.key = "LongFast", KEY + conn.setup_multicast(MCAST_GRP, MCAST_PORT) + +def on_recieve(packet: mesh_pb2.MeshPacket, addr=None): + print(f"\n[RECV] Packet received from {addr}") + print("from:", getattr(packet, "from", None)) + print("to:", packet.to) + + # Check against all public channels + matched_channel = None + for channel_name in PUBLIC_CHANNEL_IDS: + channel_hash = generate_hash(channel_name, KEY) + if packet.channel == channel_hash: + matched_channel = channel_name + break + + if matched_channel: + channel_status = f"Match ({matched_channel})" + else: + channel_status = f"Hash: {packet.channel}" + + print("channel:", channel_status) + + if packet.HasField("decoded"): + port_name = portnums_pb2.PortNum.Name(packet.decoded.portnum) if packet.decoded.portnum else "N/A" + try: + payload_decoded = True + packet_payload = packet.decoded.payload.decode("utf-8", "ignore") + except Exception: + print(" payload (raw bytes):", packet.decoded.payload) + else: + print(f"encrypted: { {packet.encrypted} }") + + + print("id:", packet.id or None) + print("rx_time:", packet.rx_time or None) + print("rx_snr:", packet.rx_snr or None) + print("hop_limit:", packet.hop_limit or None) + priority_name = mesh_pb2.MeshPacket.Priority.Name(packet.priority) if packet.priority else "N/A" + print("priority:", priority_name or None) + print("rx_rssi:", packet.rx_rssi or None) + print("hop_start:", packet.hop_start or None) + print("next_hop:", packet.next_hop or None) + print("relay_node:", packet.relay_node or None) + + print(f"decoded {{portnum: {port_name}, payload: {packet_payload if payload_decoded else 'N/A'}, bitfield: {packet.decoded.bitfield or None}}}" if packet.HasField("decoded") else "No decoded field") + +pub.subscribe(on_recieve, "mesh.rx.packet") +# pub.subscribe(on_text_message, "mesh.rx.port.1") +# pub.subscribe(on_nodeinfo, "mesh.rx.port.4") # NODEINFO_APP + +zeroconf = Zeroconf() +listener = ZeroconfListner() +browser = ServiceBrowser(zeroconf, "_meshtastic._tcp.local.", listener) + +def main(): + initalize_mudp() + mudpInterface.start() + try: + while True: time.sleep(0.05) + except KeyboardInterrupt: pass + finally: mudpInterface.stop() + +if __name__ == "__main__": + main() + + +# Meshtastic Port Numbers Reference: +# | Port Number | Name | Purpose | +# |-------------|------------------------|--------------------------------| +# | 1 | TEXT_MESSAGE_APP | Text messages | +# | 2 | POSITION_APP | Position updates (GPS) | +# | 3 | ROUTING_APP | Routing info | +# | 4 | NODEINFO_APP | Node info (name, id, etc) | +# | 5 | TELEMETRY_APP | Telemetry (battery, sensors) | +# | 6 | SERIAL_APP | Serial data | +# | 7 | ENVIRONMENTAL_APP | Environmental sensors | +# | 8 | REMOTE_HARDWARE_APP | Remote hardware control | +# | 9 | STORE_FORWARD_APP | Store and forward | +# | 10 | RANGE_TEST_APP | Range test | +# | 11 | ADMIN_APP | Admin/config | +# | 12 | WAYPOINT_APP | Waypoints | +# | 13 | CHANNEL_NODEINFO_APP | Channel node info | +# | 256 | PRIVATE_APP | Private app (custom use) | +# See: https://github.com/meshtastic/protobufs/blob/main/meshtastic/protobuf/portnums.proto + + diff --git a/modules/wiki.py b/modules/wiki.py new file mode 100644 index 0000000..4fdac0d --- /dev/null +++ b/modules/wiki.py @@ -0,0 +1,122 @@ +# meshbot wiki module + +from modules.log import * +import wikipedia # pip install wikipedia + +# Kiwix support for local wiki +if use_kiwix_server: + import requests + from bs4 import BeautifulSoup + from urllib.parse import quote + from bs4.element import Comment + +# Kiwix helper functions (only loaded if use_kiwix_server is True) +if wikipedia_enabled and use_kiwix_server: + def tag_visible(element): + """Filter visible text from HTML elements for Kiwix""" + if element.parent.name in ['style', 'script', 'head', 'title', 'meta', '[document]']: + return False + if isinstance(element, Comment): + return False + return True + + def text_from_html(body): + """Extract visible text from HTML content""" + soup = BeautifulSoup(body, 'html.parser') + texts = soup.find_all(string=True) + visible_texts = filter(tag_visible, texts) + return " ".join(t.strip() for t in visible_texts if t.strip()) + + def get_kiwix_summary(search_term): + """Query local Kiwix server for Wikipedia article""" + try: + search_encoded = quote(search_term) + # Try direct article access first + wiki_article = search_encoded.capitalize().replace("%20", "_") + exact_url = f"{kiwix_url}/raw/{kiwix_library_name}/content/A/{wiki_article}" + + response = requests.get(exact_url, timeout=urlTimeoutSeconds) + if response.status_code == 200: + # Extract and clean text + text = text_from_html(response.text) + # Remove common Wikipedia metadata prefixes + text = text.split("Jump to navigation", 1)[-1] + text = text.split("Jump to search", 1)[-1] + # Truncate to reasonable length (first few sentences) + sentences = text.split('. ') + summary = '. '.join(sentences[:wiki_return_limit]) + if summary and not summary.endswith('.'): + summary += '.' + return summary.strip()[:500] # Hard limit at 500 chars + + # If direct access fails, try search + search_url = f"{kiwix_url}/search?content={kiwix_library_name}&pattern={search_encoded}" + response = requests.get(search_url, timeout=urlTimeoutSeconds) + + if response.status_code == 200 and "No results were found" not in response.text: + soup = BeautifulSoup(response.text, 'html.parser') + links = [a['href'] for a in soup.find_all('a', href=True) if "start=" not in a['href']] + + for link in links[:3]: # Check first 3 results + article_name = link.split("/")[-1] + if not article_name or article_name[0].islower(): + continue + + article_url = f"{kiwix_url}{link}" + article_response = requests.get(article_url, timeout=urlTimeoutSeconds) + if article_response.status_code == 200: + text = text_from_html(article_response.text) + text = text.split("Jump to navigation", 1)[-1] + text = text.split("Jump to search", 1)[-1] + sentences = text.split('. ') + summary = '. '.join(sentences[:wiki_return_limit]) + if summary and not summary.endswith('.'): + summary += '.' + return summary.strip()[:500] + + logger.warning(f"System: No Kiwix Results for:{search_term}") + # try to fall back to online Wikipedia if available + return get_wikipedia_summary(search_term, force=True) + + + except requests.RequestException as e: + logger.warning(f"System: Kiwix connection error: {e}") + return "Unable to connect to local wiki server" + except Exception as e: + logger.warning(f"System: Error with Kiwix for:{search_term} {e}") + return ERROR_FETCHING_DATA + +def get_wikipedia_summary(search_term, location=None, force=False): + lat, lon = location if location else (None, None) + # Use Kiwix if configured + if use_kiwix_server and not force: + return get_kiwix_summary(search_term) + + try: + # Otherwise use online Wikipedia + wikipedia_search = wikipedia.search(search_term, results=3) + wikipedia_suggest = wikipedia.suggest(search_term) + #wikipedia_aroundme = wikipedia.geosearch(lat,lon, results=3) + #logger.debug(f"System: Wikipedia Nearby:{wikipedia_aroundme}") + except Exception as e: + logger.debug(f"System: Wikipedia search error for:{search_term} {e}") + return ERROR_FETCHING_DATA + + if len(wikipedia_search) == 0: + logger.warning(f"System: No Wikipedia Results for:{search_term}") + return ERROR_FETCHING_DATA + + try: + logger.debug(f"System: Searching Wikipedia for:{search_term}, First Result:{wikipedia_search[0]}, Suggest Word:{wikipedia_suggest}") + summary = wikipedia.summary(search_term, sentences=wiki_return_limit, auto_suggest=False, redirect=True) + except wikipedia.DisambiguationError as e: + logger.warning(f"System: Disambiguation Error for:{search_term} trying {wikipedia_search[0]}") + summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True) + except wikipedia.PageError as e: + logger.warning(f"System: Wikipedia Page Error for:{search_term} {e} trying {wikipedia_search[0]}") + summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True) + except Exception as e: + logger.warning(f"System: Error with Wikipedia for:{search_term} {e}") + return ERROR_FETCHING_DATA + + return summary \ No newline at end of file diff --git a/pong_bot.py b/pong_bot.py index a703a7f..320b30c 100755 --- a/pong_bot.py +++ b/pong_bot.py @@ -10,6 +10,7 @@ except ImportError: import asyncio import time # for sleep, get some when you can :) +from datetime import datetime import random from modules.log import * from modules.system import * @@ -29,6 +30,7 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n "cq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "cqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), "cqcqcq": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), + "echo": lambda: handle_echo(message, message_from_id, deviceID, isDM, channel_number), "lheard": lambda: handle_lheard(message, message_from_id, deviceID, isDM), "motd": lambda: handle_motd(message, MOTD), "ping": lambda: handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number), @@ -91,10 +93,13 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann else: msg = "๐Ÿ”Š Can you hear me now?" - if hop == "Direct": - msg = msg + f"SNR:{snr} RSSI:{rssi}" - else: - msg = msg + hop + # append SNR/RSSI or hop info + if hop.startswith("Direct?") and (snr != 0 or rssi != 0): + msg += f"? SNR:{snr} RSSI:{rssi}" + elif hop.startswith("Direct"): + msg += f"SNR:{snr} RSSI:{rssi}" + elif hop: + msg += f"{hop}" if "@" in message: msg = msg + " @" + message.split("@")[1] @@ -146,14 +151,48 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann return msg -def handle_motd(message): +def handle_motd(message, message_from_id, isDM): global MOTD - if "$" in message: + isAdmin = False + msg = MOTD + # check if the message_from_id is in the bbs_admin_list + if bbs_admin_list != ['']: + for admin in bbs_admin_list: + if str(message_from_id) == admin: + isAdmin = True + break + else: + isAdmin = True + + # admin help via DM + if "?" in message and isDM and isAdmin: + msg = "Message of the day, set with 'motd $ HelloWorld!'" + elif "?" in message and isDM and not isAdmin: + # non-admin help via DM + msg = "Message of the day" + elif "$" in message and isAdmin: motd = message.split("$")[1] MOTD = motd.rstrip() - return "MOTD Set to: " + MOTD + logger.debug(f"System: {message_from_id} changed MOTD: {MOTD}") + msg = "MOTD changed to: " + MOTD else: - return MOTD + msg = "MOTD: " + MOTD + return msg + +def handle_echo(message, message_from_id, deviceID, isDM, channel_number): + if "?" in message.lower(): + return "echo command returns your message back to you. Example:echo Hello World" + elif "echo " in message.lower(): + parts = message.lower().split("echo ", 1) + if len(parts) > 1 and parts[1].strip() != "": + echo_msg = parts[1] + if channel_number != echoChannel: + echo_msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + echo_msg + return echo_msg + else: + return "Please provide a message to echo back to you. Example:echo Hello World" + else: + return "Please provide a message to echo back to you. Example:echo Hello World" def sysinfo(message, message_from_id, deviceID): if "?" in message: @@ -169,14 +208,6 @@ def handle_lheard(message, nodeid, deviceID, isDM): bot_response = "Last Heard\n" bot_response += str(get_node_list(1)) - # show last users of the bot with the cmdHistory list - history = handle_history(message, nodeid, deviceID, isDM, lheard=True) - if history: - bot_response += f'LastSeen\n{history}' - else: - # trim the last \n - bot_response = bot_response[:-1] - # bot_response += getNodeTelemetry(deviceID) return bot_response @@ -189,11 +220,16 @@ def onReceive(packet, interface): rxType = type(interface).__name__ # Valies assinged to the packet - rxNode, message_from_id, snr, rssi, hop, hop_away, channel_number = 0, 0, 0, 0, 0, 0, 0 + rxNode = message_from_id = snr = rssi = hop = hop_away = channel_number = hop_start = hop_count = hop_limit = 0 pkiStatus = (False, 'ABC') replyIDset = False + rxNodeHostName = None emojiSeen = False + simulator_flag = False isDM = False + channel_name = "unknown" + session_passkey = None + playingGame = False if DEBUGpacket: # Debug print the interface object @@ -202,45 +238,60 @@ def onReceive(packet, interface): # Debug print the packet for debugging logger.debug(f"Packet Received\n {packet} \n END of packet \n") - # set the value for the incomming interface - if rxType == 'SerialInterface': - rxInterface = interface.__dict__.get('devPath', 'unknown') - if port1 in rxInterface: rxNode = 1 - elif multiple_interface and port2 in rxInterface: rxNode = 2 - elif multiple_interface and port3 in rxInterface: rxNode = 3 - elif multiple_interface and port4 in rxInterface: rxNode = 4 - elif multiple_interface and port5 in rxInterface: rxNode = 5 - elif multiple_interface and port6 in rxInterface: rxNode = 6 - elif multiple_interface and port7 in rxInterface: rxNode = 7 - elif multiple_interface and port8 in rxInterface: rxNode = 8 - elif multiple_interface and port9 in rxInterface: rxNode = 9 - + # determine the rxNode based on the interface type if rxType == 'TCPInterface': rxHost = interface.__dict__.get('hostname', 'unknown') - if rxHost and hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1 - elif multiple_interface and rxHost and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2 - elif multiple_interface and rxHost and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3 - elif multiple_interface and rxHost and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4 - elif multiple_interface and rxHost and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5 - elif multiple_interface and rxHost and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6 - elif multiple_interface and rxHost and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7 - elif multiple_interface and rxHost and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8 - elif multiple_interface and rxHost and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9 + rxNodeHostName = interface.__dict__.get('ip', None) + rxNode = next( + (i for i in range(1, 10) + if multiple_interface and rxHost and + globals().get(f'hostname{i}', '').split(':', 1)[0] in rxHost and + globals().get(f'interface{i}_type', '') == 'tcp'),None) - if rxType == 'BLEInterface': - if interface1_type == 'ble': rxNode = 1 - elif multiple_interface and interface2_type == 'ble': rxNode = 2 - elif multiple_interface and interface3_type == 'ble': rxNode = 3 - elif multiple_interface and interface4_type == 'ble': rxNode = 4 - elif multiple_interface and interface5_type == 'ble': rxNode = 5 - elif multiple_interface and interface6_type == 'ble': rxNode = 6 - elif multiple_interface and interface7_type == 'ble': rxNode = 7 - elif multiple_interface and interface8_type == 'ble': rxNode = 8 - elif multiple_interface and interface9_type == 'ble': rxNode = 9 + if rxType == 'SerialInterface': + rxInterface = interface.__dict__.get('devPath', 'unknown') + rxNode = next( + (i for i in range(1, 10) + if globals().get(f'port{i}', '') in rxInterface),None) - # check if the packet has a channel flag use it + if rxType == 'BLEInterface': + rxNode = next( + (i for i in range(1, 10) + if globals().get(f'interface{i}_type', '') == 'ble'),0) + + if rxNode is None: + # default to interface 1 ## FIXME needs better like a default interface setting or hash lookup + if 'decoded' in packet and packet['decoded']['portnum'] in ['ADMIN_APP', 'SIMULATOR_APP']: + session_passkey = packet.get('decoded', {}).get('admin', {}).get('sessionPasskey', None) + rxNode = 1 + + # check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup if packet.get('channel'): - channel_number = packet.get('channel', 0) + channel_number = packet.get('channel') + # get channel name from channel number from connected devices + for device in channel_list: + if device["interface_id"] == rxNode: + device_channels = device['channels'] + for chan_name, info in device_channels.items(): + if info['number'] == channel_number: + channel_name = chan_name + break + + # get channel hashes for the interface + device = next((d for d in channel_list if d["interface_id"] == rxNode), None) + if device: + # Find the channel name whose hash matches channel_number + for chan_name, info in device['channels'].items(): + if info['hash'] == channel_number: + print(f"Matched channel hash {info['hash']} to channel name {chan_name}") + channel_name = chan_name + break + + # check if the packet has a simulator flag + simulator_flag = packet.get('decoded', {}).get('simulator', False) + if isinstance(simulator_flag, dict): + # assume Software Simulator + simulator_flag = True # set the message_from_id message_from_id = packet['from'] @@ -254,6 +305,8 @@ def onReceive(packet, interface): if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': message_bytes = packet['decoded']['payload'] message_string = message_bytes.decode('utf-8') + via_mqtt = packet['decoded'].get('viaMqtt', False) + transport_mechanism = packet['decoded'].get('transport_mechanism', 'unknown') # check if the packet is from us if message_from_id == myNodeNum1 or message_from_id == myNodeNum2: @@ -266,39 +319,58 @@ def onReceive(packet, interface): # check if the packet has a publicKey flag use it if packet.get('publicKey'): - pkiStatus = (packet.get('pkiEncrypted', False), packet.get('publicKey', 'ABC')) + pkiStatus = packet.get('pkiEncrypted', False), packet.get('publicKey', 'ABC') + + # check if the packet has replyId flag // currently unused in the code + if packet.get('replyId'): + replyIDset = packet.get('replyId', False) + + # check if the packet has emoji flag set it // currently unused in the code + if packet.get('emoji'): + emojiSeen = packet.get('emoji', False) # check if the packet has a hop count flag use it if packet.get('hopsAway'): hop_away = packet.get('hopsAway', 0) - else: - # if the packet does not have a hop count try other methods - if packet.get('hopLimit'): - hop_limit = packet.get('hopLimit', 0) - else: - hop_limit = 0 - - if packet.get('hopStart'): - hop_start = packet.get('hopStart', 0) - else: - hop_start = 0 - if hop_start == hop_limit: - hop = "Direct" - hop_count = 0 - elif hop_start == 0 and hop_limit > 0: - hop = "MQTT" - hop_count = 0 - else: - # set hop to Direct if the message was sent directly otherwise set the hop count - if hop_away > 0: - hop_count = hop_away - else: - hop_count = hop_start - hop_limit - #print (f"calculated hop count: {hop_start} - {hop_limit} = {hop_count}") + if packet.get('hopStart'): + hop_start = packet.get('hopStart', 0) - hop = f"{hop_count} hops" + if packet.get('hopLimit'): + hop_limit = packet.get('hopLimit', 0) + # calculate hop count + hop = "" + if hop_limit > 0 and hop_start >= hop_limit: + hop_count = hop_away + (hop_start - hop_limit) + elif hop_limit > 0 and hop_start < hop_limit: + hop_count = hop_away + (hop_limit - hop_start) + else: + hop_count = hop_away + + if hop == "" and hop_count > 0: + hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops" + + if hop_start == hop_limit and "lora" in str(transport_mechanism).lower(): + hop = "Direct" + + if ((hop_start == 0 and hop_limit >= 0) or via_mqtt or ("mqtt" in str(transport_mechanism).lower())): + hop = "MQTT" + + ## FIXME should this be here? + if hop == "" and hop_count ==0 and (snr != 0 or rssi != 0): + hop = "Direct?" + + if "unknown" in str(transport_mechanism).lower() and (snr == 0 and rssi == 0): + hop = "IP-Network" + + if enableHopLogs: + 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}") + + # check with stringSafeChecker if the message is safe + if stringSafeCheck(message_string) is False: + logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}") + if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string: # ignore help and welcome messages logger.warning(f"Got Own Welcome/Help header. From: {get_name_from_number(message_from_id, 'long', rxNode)}") @@ -318,7 +390,6 @@ def onReceive(packet, interface): else: 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) - time.sleep(responseDelay) # log the message to the message log if log_messages_to_file: @@ -381,7 +452,7 @@ def onReceive(packet, interface): time.sleep(responseDelay) else: # Evaluate non TEXT_MESSAGE_APP packets - consumeMetadata(packet, rxNode) + consumeMetadata(packet, rxNode, channel_number) except KeyError as e: logger.critical(f"System: Error processing packet: {e} Device:{rxNode}") logger.debug(f"System: Error Packet = {packet}") @@ -397,26 +468,29 @@ async def start_rx(): logger.info(f"System: Autoresponder Started for Device{i} {get_name_from_number(myNodeNum, 'long', i)}," f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}") + if useDMForResponse: + logger.debug(f"System: Respond by DM only") if log_messages_to_file: logger.debug("System: Logging Messages to disk") if syslog_to_file: logger.debug("System: Logging System Logs to disk") - if solar_conditions_enabled: - logger.debug("System: Celestial Telemetry Enabled") if motd_enabled: logger.debug(f"System: MOTD Enabled using {MOTD}") + if enableEcho: + logger.debug(f"System: Echo command Enabled") if sentry_enabled: logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel}") - if store_forward_enabled: - logger.debug(f"System: Store and Forward Enabled using limit: {storeFlimit}") - if useDMForResponse: - logger.debug(f"System: Respond by DM only") + if highfly_enabled: + logger.debug(f"System: HighFly Enabled using {highfly_altitude}m limit reporting to channel:{highfly_channel}") if repeater_enabled and multiple_interface: logger.debug(f"System: Repeater Enabled for Channels: {repeater_channels}") - if file_monitor_enabled: - logger.debug(f"System: File Monitor Enabled for {file_monitor_file_path}, broadcasting to channels: {file_monitor_broadcastCh}") - if read_news_enabled: - logger.debug(f"System: File Monitor News Reader Enabled for {news_file_path}") + if bbs_enabled: + logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}") + if bbs_link_enabled: + if len(bbs_link_whitelist) > 0: + logger.debug(f"System: BBS Link Enabled with {len(bbs_link_whitelist)} peers") + else: + logger.debug(f"System: BBS Link Enabled allowing all") if scheduler_enabled: # Examples of using the scheduler, Times here are in 24hr format # https://schedule.readthedocs.io/en/stable/ @@ -433,14 +507,41 @@ async def start_rx(): # Hello World async def main(): - meshRxTask = asyncio.create_task(start_rx()) - watchdogTask = asyncio.create_task(watchdog()) - if file_monitor_enabled: - fileMonTask: asyncio.Task = asyncio.create_task(handleFileWatcher()) - - await asyncio.gather(meshRxTask, watchdogTask) - if file_monitor_enabled: - await asyncio.gather(fileMonTask) + tasks = [] + + try: + # Create core tasks + tasks.append(asyncio.create_task(start_rx(), name="pong_rx")) + tasks.append(asyncio.create_task(watchdog(), name="watchdog")) + + # Add optional tasks + if file_monitor_enabled: + tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor")) + + logger.debug(f"System: Starting {len(tasks)} async tasks") + + # Wait for all tasks with proper exception handling + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Check for exceptions in results + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"Task {tasks[i].get_name()} failed with: {result}") + + except Exception as e: + logger.error(f"Main loop error: {e}") + finally: + # Cleanup tasks + logger.debug("System: Cleaning up async tasks") + for task in tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + logger.debug(f"Task {task.get_name()} cancelled successfully") + except Exception as e: + logger.warning(f"Error cancelling task {task.get_name()}: {e}") await asyncio.sleep(0.01) diff --git a/script/addFav.py b/script/addFav.py new file mode 100644 index 0000000..3afdc4e --- /dev/null +++ b/script/addFav.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# Add a favorite node to all interfaces from config.ini data +# meshing-around - helper script +import sys +import os +import pickle +import argparse + +favList = [] +roofNodeList = [] +roof_node = False + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Add favorite nodes or print pickle contents.") + parser.add_argument('-pickle', '-p', action='store_true', help="Print the contents of roofNodeList.pkl and exit") + args = parser.parse_args() + + if args.pickle: + try: + with open('roofNodeList.pkl', 'rb') as f: + data = pickle.load(f) + #print a simple list of nodeID:x\n + for item in data: + print(f"{item.get('nodeID', 'N/A')}") + except Exception as e: + print(f"Error reading roofNodeList.pkl: {e}") + exit(0) + +# welcome header +print("meshing-around: addFav - Auto-Add favorite nodes to all interfaces from config.ini data") +print("This script may need API improvments still in progress") +print("---------------------------------------------------------------") + +try: + # set the path to import the modules and config.ini + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + from modules.log import * + from modules.system import * +except Exception as e: + print(f"Error importing modules run this program from the main repo directory 'python3 script/addFav.py'") + print(f"if you forgot the rest of it.. git clone https://github.com/spudgunman/meshing-around") + print(f"Import Error: {e}") + exit(1) + +try: + # ask if we are running on a roof node + print("This script can be run on a client_base or on the bot under a roof node.") + print("The purpose of this script is to add favorite nodes to the bot to retain DM keys.") + print("If you are running this script on a roof (base) node, stop and rerun it on the bot first to collect all node ID's.") + roof_node = input("Are you running this script on a client_base node which has no BOT? (y/n): ").strip().lower() + if roof_node not in ['y', 'n']: + raise ValueError("Invalid input. Please enter 'y' or 'n'.") + roof_node = (roof_node == 'y') +except Exception as e: + print(f"Error: {e}") + exit(1) + +try: + if roof_node: + # load roofNodeList from pickle file + try: + with open('roofNodeList.pkl', 'rb') as f: + roofNodeList = pickle.load(f) + logger.info(f"addFav: Loaded {len(roofNodeList)} connected nodes from roofNodeList.pkl for use on roof client_base only") + print(f"Loaded {len(roofNodeList)} connected nodes from roofNodeList.pkl for use on roof client_base only") + except Exception as e: + logger.error(f"addFav: Error loading roofNodeList.pkl: {e} - run this program from the main program directory 'python3 script/addFav.py'") + exit(1) + favList = roofNodeList + else: + # compile the favorite list wich returns node,interface tuples + roofNodeList = compileFavoriteList(True) + favList = compileFavoriteList(False) + + #combine favList and roofNodeList to save for next step + for node in roofNodeList: + if node not in favList: + favList.append(node) + + #save roofNodeList to a pickle file for running on the roof node + with open('roofNodeList.pkl', 'wb') as f: + pickle.dump(roofNodeList, f) + logger.info(f"addFav: Saved {len(roofNodeList)} connected nodes to roofNodeList.pkl for use on roof client_base only") + print(f"Saved {len(roofNodeList)} connected nodes to roofNodeList.pkl for use on roof client_base only") + +except Exception as e: + logger.error(f"addFav: Error compiling favorite list: {e} - run this program from the main program directory 'python3 script/addFav.py'") + exit(1) + +#confirm you want all these added +try: + if favList: + print(f"The following {len(favList)} favorite nodes will be added to the device(s):") + count_devices = set([fav['deviceID'] for fav in favList]) + count_nodes = set([fav['nodeID'] for fav in favList]) + for fav in favList: + print(f"addFav: adding nodeID {fav['nodeID']} meshtastic --set-favorite-node {fav['nodeID']}") + confirm = input(f"Are you sure you want to add these {len(count_nodes)} favorite nodes to {len(count_devices)} device(s)? (y/n): ").strip().lower() + if confirm != 'y': + print("Operation cancelled by user.") + exit(0) + else: + print("No favorite nodes to add to device(s). Exiting.") + exit(0) +except Exception as e: + logger.error(f"addFav: Error during confirmation: {e}") + exit(1) + +if favList: + # for each node,interface tuple add the favorite node + for fav in favList: + try: + handleFavoriteNode(fav['deviceID'], fav['nodeID'], True) + logger.info(f"addFav: waiting 15 seconds to avoid API rate limits") + time.sleep(15) # wait to avoid API rate limits + except Exception as e: + logger.error(f"addFav: Error adding favorite node {fav['nodeID']} to device {fav['deviceID']}: {e}") +else: + logger.info("addFav: No favorite nodes to add to device(s)") + exit(0) + +count_devices = set([fav['deviceID'] for fav in favList]) +count_nodes = set([fav['nodeID'] for fav in favList]) +logger.info(f"addFav: Finished adding {len(count_nodes)} favorite nodes to {len(count_devices)} device(s)") +logger.info("addFav: You may need to restart the mesh service on the device(s)") +print(f"Finished adding {len(count_nodes)} favorite nodes to {len(count_devices)} device(s)") +print(f"Data file for roof client_base has been saved to roofNodeList.pkl") +if not roof_node: + logger.info(f"addFav: You can now run this repo+script & roofNodeList.pkl on the roof node to add the favorite nodes to the roof client_base") +exit(0) diff --git a/script/configMerge.py b/script/configMerge.py new file mode 100644 index 0000000..2411563 --- /dev/null +++ b/script/configMerge.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Configuration Merge Script +# Merges user configuration with default settings +# 2025 Kelly Keeton K7MHI mesh-around and its meshtastic +import shutil +import configparser +import os + + +def merge_configs(default_config_path, user_config_path, output_config_path): + # Load default configuration (INI) + default_config = configparser.ConfigParser() + default_config.read(default_config_path) + + # Load user configuration (INI) + user_config = configparser.ConfigParser() + user_config.read(user_config_path) + + # Merge configurations + for section in user_config.sections(): + if not default_config.has_section(section): + default_config.add_section(section) + for key, value in user_config.items(section): + default_config.set(section, key, value) + + # Save merged configuration as INI + with open(output_config_path, 'w', encoding='utf-8') as f: + default_config.write(f) + +def backup_config(config_path, backup_path): + shutil.copyfile(config_path, backup_path) + +def show_config_changes(user_config_path, merged_config_path): + if not os.path.exists(merged_config_path) or os.path.getsize(merged_config_path) == 0: + print(f"Error: {merged_config_path} is empty or missing!") + return + + # Load user config (as dict) + user_config = configparser.ConfigParser() + user_config.read(user_config_path) + user_dict = {s: dict(user_config.items(s)) for s in user_config.sections()} + + # Load merged config (as dict) + merged_config = configparser.ConfigParser() + merged_config.read(merged_config_path) + merged_dict = {s: dict(merged_config.items(s)) for s in merged_config.sections()} + + print("\n--- Changes in merged configuration ---") + for section in merged_dict: + if section not in user_dict: + print(f"[{section}] (new section)") + for k, v in merged_dict[section].items(): + print(f" {k} = {v} (added)") + else: + for k, v in merged_dict[section].items(): + if k not in user_dict[section]: + print(f"[{section}] {k} = {v} (added)") + elif user_dict[section][k] != v: + print(f"[{section}] {k}: {user_dict[section][k]} -> {v} (changed)") + print("--- End of changes ---\n") + +if __name__ == "__main__": + print("MESHING-AROUND: Configuration Merge Script for config.ini checking updates from config.template") + print("---------------------------------------------------------------") + master_config_path = 'config.template' + user_config_path = 'config.ini' + output_config = 'config_new.ini' + backup_config_path = 'config.bak' + + # Step 1: Check master config + try: + if not os.path.exists(master_config_path) or os.path.getsize(master_config_path) == 0: + raise FileNotFoundError(f"Master configuration file {master_config_path} is missing or empty.") + except Exception as e: + print(f"Error: {e}") + print("Run the tool from the meshing-around/script/ directory where the config.template is located.") + print(" python3 script/configMerge.py") + exit(1) + + # Step 2: Backup user config + try: + backup_config(user_config_path, backup_config_path) + print(f"Backup of user config created at {backup_config_path}") + except Exception as e: + print(f"Error backing up user config: {e}") + exit(1) + + # Step 3: Merge configs + try: + merge_configs(master_config_path, user_config_path, output_config) + print(f"Merged configuration saved to {output_config}") + except Exception as e: + print(f"Error merging configuration: {e}") + exit(1) + + # Step 4: Show changes + try: + show_config_changes(user_config_path, output_config) + print("Please review the new configuration and replace your existing config.ini if needed.") + print(" cp config_new.ini config.ini") + except Exception as e: + print(f"Error showing configuration changes: {e}") + exit(1) diff --git a/script/injectDM.py b/script/injectDM.py new file mode 100644 index 0000000..0cb9e86 --- /dev/null +++ b/script/injectDM.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Usage: python3 script/injectDM.py -s NODEID -d NODEID -m "message" +# meshing-around - helper script +import sys +import os +import argparse + +# welcome header +print("meshing-around: injectDM.py -s NODEID -d NODEID -m 'Hello World'") +print("Auto-Inject DM messages to data/bbsdm.pkl") +print(" needs config.ini [bbs] bbsAPI_enabled = True ") +print("---------------------------------------------------------------") + +try: + # set the path to import the modules and config.ini + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + from modules.log import * + from modules.bbstools import * +except Exception as e: + print(f"Error importing modules run this program from the main program directory 'python3 script/injectDM.py'") + exit(1) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Inject DM messages to data/bbsdm.pkl') + parser.add_argument('-s', '--src', type=str, required=True, help='Source NODEID') + parser.add_argument('-d', '--dst', type=str, required=True, help='Destination NODEID') + parser.add_argument('-m', '--msg', type=str, required=True, help="'Message to send'") + args = parser.parse_args() + dst = args.dst + src = args.src + message = args.msg + if not message: + logger.error("Message cannot be empty") + exit(1) + if dst == src: + logger.error("Source and Destination cannot be the same") + exit(1) + + if not isinstance(bbs_dm, list): + logger.error("bbs_dm is corrupt, something is wrong") + exit(1) + + # inject the message + if bbs_post_dm(dst, message, src): + logger.info(f"Injected message from {src} to {dst}: {message}") + else: + logger.error("Failed to inject message") + exit(1) + + # show stats get_bbs_stats + stats = get_bbs_stats() + stats = stats.replace("\n", " | ") + logger.info(f"BBS Stats: {stats}") diff --git a/script/sysEnv.sh b/script/sysEnv.sh index 388744c..1585706 100644 --- a/script/sysEnv.sh +++ b/script/sysEnv.sh @@ -42,3 +42,15 @@ then fi fi fi + +# Get public and local IP addresses +public_ip=$(curl -s https://ifconfig.me 2>/dev/null) +public_ip=${public_ip:-""} +local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') +local_ip=${local_ip:-""} +if [ -n "$public_ip" ]; then + echo "Public IP: $public_ip" +fi +if [ -n "$local_ip" ]; then + echo "Local IP: $local_ip" +fi \ No newline at end of file diff --git a/update.sh b/update.sh index cf72627..a100792 100644 --- a/update.sh +++ b/update.sh @@ -6,40 +6,81 @@ if systemctl is-active --quiet mesh_bot.service; then echo "Stopping mesh_bot.service..." systemctl stop mesh_bot.service + service_stopped=true fi if systemctl is-active --quiet pong_bot.service; then echo "Stopping pong_bot.service..." systemctl stop pong_bot.service + service_stopped=true fi if systemctl is-active --quiet mesh_bot_reporting.service; then echo "Stopping mesh_bot_reporting.service..." systemctl stop mesh_bot_reporting.service + service_stopped=true fi if systemctl is-active --quiet mesh_bot_w3.service; then echo "Stopping mesh_bot_w3.service..." systemctl stop mesh_bot_w3.service + service_stopped=true fi -# Update the local repository -echo "Updating local repository..." -#git fetch --all -#git reset --hard origin/main # Replace 'main' with your branch name if different -git pull origin main --rebase # Fetch and rebase to keep local changes if any -echo "Local repository updated." +# Fetch latest changes from GitHub +echo "Fetching latest changes from GitHub..." +if ! git fetch origin; then + echo "Error: Failed to fetch from GitHub, check your network connection." + exit 1 +fi -# Install or update dependencies -echo "Installing or updating dependencies..." -pip install -r requirements.txt --upgrade +# git pull with rebase to avoid unnecessary merge commits +echo "Pulling latest changes from GitHub..." +if ! git pull origin main --rebase; then + read -p "Git pull resulted in conflicts. Do you want to reset hard to origin/main? This will discard local changes. (y/n): " choice + if [[ "$choice" == "y" || "$choice" == "Y" ]]; then + git fetch --all + git reset --hard origin/main + echo "Local repository updated." + else + echo "Update aborted due to git conflicts." + fi +fi -echo "Dependencies installed or updated." +# Backup the data/ directory +echo "Backing up data/ directory..." +#backup_file="backup_$(date +%Y%m%d_%H%M%S).tar.gz" +backup_file="data_backup.tar.gz" +path2backup="data/" +tar -czf "$backup_file" "$path2backup" +if [ $? -ne 0 ]; then + echo "Error: Backup failed." +else + echo "Backup of ${path2backup} completed: ${backup_file}" +fi + + +# Build a config_new.ini file merging user config with new defaults +echo "Merging configuration files..." +python3 script/configMerge.py > ini_merge_log.txt 2>&1 + +if [ -f ini_merge_log.txt ]; then + if grep -q "Error during configuration merge" ini_merge_log.txt; then + echo "Configuration merge encountered errors. Please check ini_merge_log.txt for details." + else + echo "Configuration merge completed. Please review config_new.ini and ini_merge_log.txt." + fi +else + echo "Configuration merge log (ini_merge_log.txt) not found. check out the script/configMerge.py tool!" +fi + +# if service was stopped earlier, restart it +if [ "$service_stopped" = true ]; then + echo "Restarting services..." + systemctl start mesh_bot.service + systemctl start pong_bot.service + systemctl start mesh_bot_reporting.service + systemctl start mesh_bot_w3.service + echo "Services restarted." +fi -# Restart the services -echo "Restarting services..." -systemctl start mesh_bot.service -systemctl start pong_bot.service -systemctl start mesh_bot_reporting.service -systemctl start mesh_bot_w3.service -echo "Services restarted." # Print completion message echo "Update completed successfully?" exit 0