mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecc563e96 | ||
|
|
eeeb43cacc | ||
|
|
9fdcea56fc | ||
|
|
24a33fe882 | ||
|
|
5710cebf39 | ||
|
|
b66487863d | ||
|
|
b3c4d208b7 | ||
|
|
f41ff2d5f7 | ||
|
|
48366bc595 | ||
|
|
02dd64382d | ||
|
|
731b48ad65 | ||
|
|
69a7082669 | ||
|
|
fafa7d8a51 | ||
|
|
6e69b5f014 | ||
|
|
03895248cd | ||
|
|
a79de8a325 | ||
|
|
740b53f02f | ||
|
|
76e75551c6 | ||
|
|
51752ae896 | ||
|
|
d81e773c0c | ||
|
|
1f1ed1ca70 | ||
|
|
df9f3806a3 | ||
|
|
081ccd9e2e | ||
|
|
d9a7dafe6e | ||
|
|
921225965b | ||
|
|
3659254785 | ||
|
|
7c502608f6 | ||
|
|
427c25f80b | ||
|
|
c3f15390ea | ||
|
|
e1476a44c6 | ||
|
|
72070fef3e | ||
|
|
b63ea677f6 | ||
|
|
f8389500b8 | ||
|
|
b257625a45 | ||
|
|
a233d8c7b3 | ||
|
|
11c9742ebe | ||
|
|
5af28c3dc2 | ||
|
|
aebb9e3c20 | ||
|
|
d5916f4ccc | ||
|
|
056159a3f3 | ||
|
|
2f6049d94b | ||
|
|
a2d7f664ab | ||
|
|
b26491b646 | ||
|
|
22e97b0eec | ||
|
|
f540866d08 | ||
|
|
c9729c8214 | ||
|
|
49901cbbee | ||
|
|
2aa2b80935 | ||
|
|
95695f4f58 | ||
|
|
b641d2b5e8 | ||
|
|
51d8faab12 | ||
|
|
7a1396b99d | ||
|
|
819bbbcaf4 | ||
|
|
0eeda96670 | ||
|
|
18cca4ffdd | ||
|
|
d169fe2dff | ||
|
|
1c732dfe17 | ||
|
|
bdad3927e5 | ||
|
|
0e0d6416d9 | ||
|
|
0da780371a | ||
|
|
37bf30cbc0 | ||
|
|
817a8601dd | ||
|
|
47cca409be | ||
|
|
e08a82ec39 | ||
|
|
345541dfb5 | ||
|
|
6e89762f1d | ||
|
|
0fb26bc16a | ||
|
|
f1ad5966af | ||
|
|
ac57d4683f | ||
|
|
eab099e5ee | ||
|
|
685bd3491d | ||
|
|
b8d64f3a9e | ||
|
|
852d491030 | ||
|
|
76565c5546 | ||
|
|
af1ec1630e | ||
|
|
0c2b36a206 | ||
|
|
c0934096f0 | ||
|
|
819bfaba90 | ||
|
|
8041a1296b | ||
|
|
10d93b4fd3 | ||
|
|
19dedef1e6 | ||
|
|
d4af0c7e8b | ||
|
|
8730f0fd38 | ||
|
|
9cda8daf65 | ||
|
|
a9223f1613 | ||
|
|
04ca4c99b8 | ||
|
|
3072520e63 | ||
|
|
bd6603766b | ||
|
|
075a23bd2b | ||
|
|
a8e4f653ed | ||
|
|
374a44f4a9 | ||
|
|
3c8d2e646e | ||
|
|
e5df983244 | ||
|
|
fa5f9250c4 | ||
|
|
3f7a831690 | ||
|
|
89aaaddae9 | ||
|
|
e1919616c2 | ||
|
|
8b9e637006 | ||
|
|
0df3e32901 | ||
|
|
1c2fa174ea | ||
|
|
c97aefcef1 | ||
|
|
dfb94c3993 | ||
|
|
7d62f69f12 | ||
|
|
cf896767fb | ||
|
|
1eb4cf71ed | ||
|
|
e959124eac | ||
|
|
d787c72812 | ||
|
|
9f0dd56d43 | ||
|
|
aa71e6045a | ||
|
|
a140ad83cd | ||
|
|
93c2d731e8 | ||
|
|
d8da553af9 | ||
|
|
9d9f070908 | ||
|
|
0f2061af55 | ||
|
|
d8423584d4 | ||
|
|
843320d268 | ||
|
|
216128b15a | ||
|
|
f8bc574753 | ||
|
|
6193c5933f | ||
|
|
b668965bda | ||
|
|
ae039b5baf | ||
|
|
824d43f16e | ||
|
|
2de76e6c5e | ||
|
|
afb02602fd | ||
|
|
99528c2bcf | ||
|
|
b53f5821f3 | ||
|
|
93fc6547b8 | ||
|
|
9a7e321dff | ||
|
|
39257f2d39 | ||
|
|
8c5abecac3 | ||
|
|
16dcc96037 | ||
|
|
b1d32a7745 | ||
|
|
631a2f53ea | ||
|
|
32903c97e3 | ||
|
|
6e61e8122d | ||
|
|
d109803f9d | ||
|
|
09ed4f57cf | ||
|
|
acfb8078a9 | ||
|
|
84f9693833 | ||
|
|
50fdcf486d | ||
|
|
eab5afccc8 | ||
|
|
ea9db47c2d | ||
|
|
cf3a9c5b43 | ||
|
|
adedaa092c | ||
|
|
f204237a63 | ||
|
|
057a400041 | ||
|
|
4cdf68f074 | ||
|
|
003a11c557 | ||
|
|
8d309fa579 | ||
|
|
232f9c24db | ||
|
|
39dccd149b | ||
|
|
b921c73fa7 | ||
|
|
f3ec1cbe93 | ||
|
|
a6bcfda0ac | ||
|
|
51cd2002af | ||
|
|
b40f41f41c | ||
|
|
4c33b30f14 | ||
|
|
b7490afb99 | ||
|
|
8b57ed727c | ||
|
|
fd5d64b9fb | ||
|
|
00af152c2c | ||
|
|
31f0abc8c8 | ||
|
|
6b7d795a31 | ||
|
|
1f093c4bc2 | ||
|
|
fe1c4a1ad0 | ||
|
|
11687cb7ba | ||
|
|
b07a7fb0cc | ||
|
|
b876d87ba9 | ||
|
|
0a63e89633 | ||
|
|
848f5609c2 | ||
|
|
0ccbed6165 | ||
|
|
646517db71 | ||
|
|
7d347bb80a | ||
|
|
e199d4f5eb | ||
|
|
a9767b58c4 | ||
|
|
69dfde047e | ||
|
|
da33b6f1b9 | ||
|
|
8a7125358b | ||
|
|
ae558052f7 | ||
|
|
5074d71eb7 | ||
|
|
632f42477a | ||
|
|
b3df38d15e | ||
|
|
b76b8ca718 | ||
|
|
d66a9e745b | ||
|
|
717bbccea3 | ||
|
|
50fd1c0410 | ||
|
|
ae89788ea4 | ||
|
|
4220b095ee | ||
|
|
ef28341cdb | ||
|
|
b5d610728c | ||
|
|
bc238ef476 | ||
|
|
feb3544014 | ||
|
|
31322dc0cd | ||
|
|
8c1cbaf442 | ||
|
|
8d6a95b5da | ||
|
|
b4b2ef3d80 | ||
|
|
13b1b90864 | ||
|
|
838bd3edce | ||
|
|
70bcf43b49 | ||
|
|
0a02ae860e | ||
|
|
a80575a381 | ||
|
|
e57b65b447 | ||
|
|
1d18f0936c | ||
|
|
b096716b96 | ||
|
|
f4734c5b87 | ||
|
|
96447b166f | ||
|
|
2fc151bbbf | ||
|
|
e66af5c068 | ||
|
|
d3ce4d3905 | ||
|
|
a95cdeb086 | ||
|
|
27bf61a913 | ||
|
|
d62990b6db | ||
|
|
0784aaebd9 | ||
|
|
e348854a50 | ||
|
|
a71e5fa8f3 | ||
|
|
9600ea5e00 | ||
|
|
cc7461929e | ||
|
|
0c8fb0c243 | ||
|
|
311563320e | ||
|
|
78b6d660dd | ||
|
|
77da966b9d | ||
|
|
d844c123be | ||
|
|
d4d36c8a31 | ||
|
|
9acd57a420 | ||
|
|
bc06712b87 | ||
|
|
260e52fe81 | ||
|
|
6b548f82b2 | ||
|
|
2273b481ad | ||
|
|
95ee7779b4 | ||
|
|
ee1391f6e7 | ||
|
|
b7a0d7cd8e | ||
|
|
a880236117 | ||
|
|
a67bdc3641 | ||
|
|
da8235adae | ||
|
|
22384463e2 | ||
|
|
b48377de5f | ||
|
|
855c2e08cc | ||
|
|
d0aa07ed7d | ||
|
|
7328a92535 | ||
|
|
35c8dc6f70 | ||
|
|
4b1123dcac | ||
|
|
b74dc1ff25 | ||
|
|
8c752dff3e | ||
|
|
b3b45a4335 | ||
|
|
fd86187798 | ||
|
|
4da3e68c62 | ||
|
|
e47907ebeb | ||
|
|
f63278ae8f | ||
|
|
0e0d2f11d7 | ||
|
|
6587ba61e2 | ||
|
|
b46697c0c4 | ||
|
|
f5f8539924 | ||
|
|
e8063fcf3f | ||
|
|
169f9b27a5 | ||
|
|
4ceb23bcff | ||
|
|
315ae84bb6 | ||
|
|
fb12c11a7e | ||
|
|
496c222cdc | ||
|
|
cb55aba498 | ||
|
|
1aac3d5ac2 | ||
|
|
413f2a24d9 | ||
|
|
c75782d559 | ||
|
|
d38314a21c | ||
|
|
65dfe90edc | ||
|
|
3ce24fb7c9 | ||
|
|
8765e5a871 | ||
|
|
b4f0421423 | ||
|
|
2b8906ae55 | ||
|
|
5710b47a99 | ||
|
|
1f8bf5a700 | ||
|
|
171480b704 | ||
|
|
c74e4f99b2 | ||
|
|
cd5749521c | ||
|
|
5ce7019dba | ||
|
|
6c1e0cc2f9 | ||
|
|
68b171f68e | ||
|
|
7cfd5d0b0e | ||
|
|
6dd4f0c4b6 | ||
|
|
8ef0fa2ac0 | ||
|
|
0c8d6b8fac | ||
|
|
1e4e5e6627 | ||
|
|
c97004b410 | ||
|
|
2292fb2655 | ||
|
|
3ebf3ba374 | ||
|
|
b6087c926c | ||
|
|
2895e6c034 | ||
|
|
691bc8d701 | ||
|
|
bd50524e95 | ||
|
|
299b749f0e | ||
|
|
9a060e3c6e | ||
|
|
a012ef17d0 | ||
|
|
adbf78b740 | ||
|
|
3aad8d89cf | ||
|
|
3370304249 | ||
|
|
ef62a06db1 | ||
|
|
8cc1d24b93 | ||
|
|
fca90cbee3 | ||
|
|
d05c7bb6a5 | ||
|
|
7774529fb4 | ||
|
|
4c615af22d | ||
|
|
6c078b4d17 | ||
|
|
ddb9c8b4bf | ||
|
|
73f3175705 | ||
|
|
d2ee1bce1c | ||
|
|
b4a2149815 | ||
|
|
320f41e05a | ||
|
|
48a57e875f | ||
|
|
ce317d8bbe | ||
|
|
c2d2a8f7e4 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -23,7 +23,8 @@ data/rag/*
|
||||
# qrz db
|
||||
data/qrz.db
|
||||
|
||||
# fileMon
|
||||
news.txt
|
||||
alert.txt
|
||||
# fileMonitor test file
|
||||
bee.txt
|
||||
|
||||
# .csv files
|
||||
*.csv
|
||||
95
README.md
95
README.md
@@ -41,6 +41,15 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
|
||||
### Proximity Alerts
|
||||
- **Location-Based Alerts**: Get notified when members arrive back at a configured lat/long, perfect for remote locations like campsites.
|
||||
- **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. Useful foraccountability of people, assets. Radio-Net, FEMA, Trailhead.
|
||||
@@ -49,10 +58,21 @@ Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance yo
|
||||
- **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
|
||||
@@ -63,11 +83,12 @@ 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.
|
||||
- **Shell Command Access**: Pass commands via DM directly to the host OS
|
||||
- **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.
|
||||
@@ -92,14 +113,16 @@ git clone https://github.com/spudgunman/meshing-around
|
||||
| `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) via DM only | ✅ |
|
||||
| `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 Forecasting
|
||||
| Command | Description | |
|
||||
@@ -108,7 +131,7 @@ git clone https://github.com/spudgunman/meshing-around
|
||||
| `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) | |
|
||||
@@ -138,9 +161,10 @@ git clone https://github.com/spudgunman/meshing-around
|
||||
|---------|-------------|-
|
||||
| `askai` and `ask:` | Ask Ollama LLM AI for a response. Example: `askai what temp do I cook chicken` | ✅ |
|
||||
| `messages` | Replays the last messages heard on device, like Store and Forward, returns the PublicChannel and Current | ✅ |
|
||||
| `readnews` | returns the contents of a file (news.txt, by default) via the chunker on air | ✅ |
|
||||
| `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 | ✅ |
|
||||
|
||||
@@ -162,8 +186,19 @@ git clone https://github.com/spudgunman/meshing-around
|
||||
| `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 <message>` 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: <answer>`
|
||||
|
||||
#### Survey
|
||||
To use the Survey feature edit the json files in data/survey multiple surveys are possible such as `survey snow`
|
||||
|
||||
## Other Install Options
|
||||
|
||||
### Docker Installation - handy for windows
|
||||
@@ -194,7 +229,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]
|
||||
@@ -229,6 +264,11 @@ 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
|
||||
|
||||
coastalEnabled = False # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
|
||||
@@ -377,6 +417,33 @@ googleSearchResults = 3 # number of google search results to include in the cont
|
||||
```
|
||||
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.**
|
||||
|
||||
@@ -431,7 +498,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
|
||||
@@ -454,7 +526,7 @@ value = # value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun
|
||||
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
|
||||
@@ -465,7 +537,7 @@ schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now
|
||||
```
|
||||
|
||||
#### BBS Link
|
||||
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 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))
|
||||
@@ -523,7 +595,8 @@ I used ideas and snippets from other responder bots and want to call them out!
|
||||
- **mikecarper**: ideas, and testing. hamtest
|
||||
- **c.merphy360**: high altitude alerts
|
||||
- **Iris**: testing and finding 🐞
|
||||
- **Cisien, bitflip, Woof, propstg, trs2982, Josh, mesb1, and Hailo1999**: For testing and feature ideas on Discord and GitHub.
|
||||
- **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
|
||||
|
||||
@@ -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]
|
||||
@@ -55,8 +55,23 @@ 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
|
||||
@@ -73,6 +88,7 @@ rawLLMQuery = True
|
||||
# StoreForward Enabled and Limits
|
||||
StoreForward = True
|
||||
StoreLimit = 3
|
||||
reverseSF = False
|
||||
|
||||
# history command
|
||||
enableCmdHistory = True
|
||||
@@ -82,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
|
||||
@@ -111,6 +127,7 @@ alert_interface = 1
|
||||
[sentry]
|
||||
# detect anyone close to the bot
|
||||
SentryEnabled = True
|
||||
reqLocationEnabled = False
|
||||
emailSentryAlerts = False
|
||||
# radius in meters to detect someone close to the bot
|
||||
SentryRadius = 100
|
||||
@@ -132,6 +149,7 @@ highFlyingAlertAltitude = 2000
|
||||
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 =
|
||||
@@ -154,6 +172,8 @@ bbsAPI_enabled = False
|
||||
enabled = True
|
||||
lat = 48.50
|
||||
lon = -123.0
|
||||
fuzzConfigLocation = True
|
||||
fuzzItAll = False
|
||||
|
||||
# Default to metric units rather than imperial
|
||||
useMetric = False
|
||||
@@ -179,7 +199,8 @@ myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz1
|
||||
# number of data points to return, default is 3
|
||||
coastalForecastDays = 3
|
||||
|
||||
# NOAA USGS Hydrology river identifiers, LID or USGS ID https://waterdata.usgs.gov
|
||||
# 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
|
||||
@@ -254,7 +275,9 @@ 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. or custom for module/scheduler.py
|
||||
value =
|
||||
# interval to use when time is not set (e.g. every 2 days)
|
||||
interval =
|
||||
@@ -265,7 +288,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
|
||||
@@ -274,6 +299,17 @@ 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
|
||||
@@ -284,7 +320,7 @@ 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
|
||||
|
||||
@@ -293,6 +329,10 @@ 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
|
||||
@@ -335,22 +375,35 @@ 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
|
||||
|
||||
1
data/mesh_news.txt
Normal file
1
data/mesh_news.txt
Normal file
@@ -0,0 +1 @@
|
||||
Today in meshtastic you are looking at the coolest bot on the block.
|
||||
16
data/quiz_questions.json
Normal file
16
data/quiz_questions.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
15
data/surveys/example_survey.json
Normal file
15
data/surveys/example_survey.json
Normal file
@@ -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?"
|
||||
}
|
||||
]
|
||||
15
data/surveys/snow_survey.json
Normal file
15
data/surveys/snow_survey.json
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
224
etc/meshview.ino
Normal file
224
etc/meshview.ino
Normal file
@@ -0,0 +1,224 @@
|
||||
// Example to receive and decode Meshtastic UDP packets
|
||||
// Make sure to install the meashtastic library and generate the .pb.h and .pb.c files from the Meshtastic .proto definitions
|
||||
// https://github.com/meshtastic/protobufs/tree/master/meshtastic
|
||||
|
||||
// Example to receive and decode Meshtastic UDP packets
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <WiFiUdp.h>
|
||||
// #include <AESLib.h> // or another AES library
|
||||
|
||||
#include "pb_decode.h"
|
||||
#include "meshtastic/mesh.pb.h" // MeshPacket, Position, etc.
|
||||
#include "meshtastic/portnums.pb.h" // Port numbers enum
|
||||
#include "meshtastic/telemetry.pb.h" // Telemetry message
|
||||
|
||||
const char* ssid = "YOUR_WIFI_SSID";
|
||||
const char* password = "YOUR_WIFI_PASSWORD";
|
||||
|
||||
const char* default_key = "1PG7OiApB1nwvP+rz05pAQ=="; // Your network key here
|
||||
uint8_t aes_key[16]; // Buffer for decoded key
|
||||
|
||||
const char* MCAST_GRP = "224.0.0.69";
|
||||
const uint16_t MCAST_PORT = 4403;
|
||||
|
||||
unsigned long udpPacketCount = 0;
|
||||
|
||||
WiFiUDP udp;
|
||||
IPAddress multicastIP;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000);
|
||||
|
||||
Serial.println("Scanning for WiFi networks...");
|
||||
int n = WiFi.scanNetworks();
|
||||
if (n == 0) {
|
||||
Serial.println("No networks found.");
|
||||
} else {
|
||||
Serial.print(n);
|
||||
Serial.println(" networks found:");
|
||||
for (int i = 0; i < n; ++i) {
|
||||
Serial.print(i + 1);
|
||||
Serial.print(": ");
|
||||
Serial.print(WiFi.SSID(i));
|
||||
Serial.print(" (RSSI ");
|
||||
Serial.print(WiFi.RSSI(i));
|
||||
Serial.print(")");
|
||||
Serial.println((WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? " [OPEN]" : " [SECURED]");
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println("Connecting to WiFi...");
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, password);
|
||||
|
||||
unsigned long startAttemptTime = millis();
|
||||
const unsigned long wifiTimeout = 20000;
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < wifiTimeout) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("\nWiFi connected.");
|
||||
Serial.print("IP address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
multicastIP.fromString(MCAST_GRP);
|
||||
if (udp.beginMulticast(multicastIP, MCAST_PORT)) {
|
||||
Serial.println("UDP multicast listener started.");
|
||||
} else {
|
||||
Serial.println("Failed to start UDP multicast listener.");
|
||||
}
|
||||
} else {
|
||||
Serial.print("\nFailed to connect to WiFi. SSID: ");
|
||||
Serial.println(ssid);
|
||||
Serial.println("Check SSID, range, and password.");
|
||||
}
|
||||
}
|
||||
|
||||
void printHex(const uint8_t* buf, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
Serial.printf("%02X ", buf[i]);
|
||||
}
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
void printAscii(const uint8_t* buf, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = static_cast<char>(buf[i]);
|
||||
Serial.print(isprint(c) ? c : '.');
|
||||
}
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
void decodeKey() {
|
||||
// Convert base64 key to raw bytes
|
||||
// You may need to add a base64 decoding function/library
|
||||
// Example: decode_base64(default_key, aes_key, sizeof(aes_key));
|
||||
}
|
||||
|
||||
void decryptPayload(const uint8_t* encrypted, size_t len, uint8_t* decrypted) {
|
||||
// Use AESLib or similar to decrypt
|
||||
// Example: aes128_dec_single(decrypted, encrypted, aes_key);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
int packetSize = udp.parsePacket();
|
||||
if (!packetSize) {
|
||||
delay(50);
|
||||
return;
|
||||
}
|
||||
|
||||
udpPacketCount++;
|
||||
Serial.print("UDP packets seen: ");
|
||||
Serial.println(udpPacketCount);
|
||||
|
||||
uint8_t buffer[512];
|
||||
int len = udp.read(buffer, sizeof(buffer));
|
||||
if (len <= 0) {
|
||||
Serial.println("Failed to read UDP packet.");
|
||||
delay(50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always show raw payload
|
||||
Serial.print("Raw UDP payload (hex): ");
|
||||
printHex(buffer, len);
|
||||
Serial.print("Raw UDP payload (ASCII): ");
|
||||
printAscii(buffer, len);
|
||||
|
||||
// Decode outer MeshPacket
|
||||
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
|
||||
pb_istream_t stream = pb_istream_from_buffer(buffer, len);
|
||||
|
||||
if (!pb_decode(&stream, meshtastic_MeshPacket_fields, &pkt)) {
|
||||
Serial.println("Failed to decode meshtastic_MeshPacket.");
|
||||
delay(50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic MeshPacket fields
|
||||
Serial.print("id: "); Serial.println(pkt.id);
|
||||
Serial.print("rx_time: "); Serial.println(pkt.rx_time);
|
||||
Serial.print("rx_snr: "); Serial.println(pkt.rx_snr, 2);
|
||||
Serial.print("rx_rssi: "); Serial.println(pkt.rx_rssi);
|
||||
Serial.print("hop_limit: "); Serial.println(pkt.hop_limit);
|
||||
Serial.print("priority: "); Serial.println(pkt.priority);
|
||||
Serial.print("from: "); Serial.println(pkt.from);
|
||||
Serial.print("to: "); Serial.println(pkt.to);
|
||||
Serial.print("channel: "); Serial.println(pkt.channel);
|
||||
|
||||
// Only proceed if we have a decoded Data variant
|
||||
if (pkt.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
||||
Serial.println("Packet does not contain decoded Data (maybe encrypted or other variant).");
|
||||
delay(50);
|
||||
return;
|
||||
}
|
||||
|
||||
const meshtastic_Data& data = pkt.decoded;
|
||||
Serial.print("Portnum: "); Serial.println(data.portnum);
|
||||
Serial.print("Payload size: "); Serial.println(data.payload.size);
|
||||
|
||||
if (data.payload.size == 0) {
|
||||
Serial.println("No inner payload bytes.");
|
||||
delay(50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode by portnum
|
||||
switch (data.portnum) {
|
||||
|
||||
case meshtastic_PortNum_TEXT_MESSAGE_APP: {
|
||||
// Current schemas do not use a separate user.pb.h. Text payload is plain bytes.
|
||||
Serial.print("Decoded text message: ");
|
||||
printAscii(data.payload.bytes, data.payload.size);
|
||||
break;
|
||||
}
|
||||
|
||||
case meshtastic_PortNum_POSITION_APP: {
|
||||
meshtastic_Position pos = meshtastic_Position_init_zero;
|
||||
pb_istream_t ps = pb_istream_from_buffer(data.payload.bytes, data.payload.size);
|
||||
if (pb_decode(&ps, meshtastic_Position_fields, &pos)) {
|
||||
Serial.print("Position lat="); Serial.print(pos.latitude_i / 1e7, 7);
|
||||
Serial.print(" lon="); Serial.print(pos.longitude_i / 1e7, 7);
|
||||
Serial.print(" alt="); Serial.println(pos.altitude);
|
||||
} else {
|
||||
Serial.println("Failed to decode Position payload.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case meshtastic_PortNum_TELEMETRY_APP: {
|
||||
meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero;
|
||||
pb_istream_t ts = pb_istream_from_buffer(data.payload.bytes, data.payload.size);
|
||||
if (pb_decode(&ts, meshtastic_Telemetry_fields, &tel)) {
|
||||
// Print a few common fields if present
|
||||
if (tel.which_variant == meshtastic_Telemetry_device_metrics_tag) {
|
||||
const meshtastic_DeviceMetrics& m = tel.variant.device_metrics;
|
||||
Serial.print("Telemetry battery_level="); Serial.print(m.battery_level);
|
||||
Serial.print(" voltage="); Serial.print(m.voltage);
|
||||
Serial.print(" air_util_tx="); Serial.println(m.air_util_tx);
|
||||
} else {
|
||||
Serial.println("Telemetry decoded, different variant. Raw bytes:");
|
||||
printHex(data.payload.bytes, data.payload.size);
|
||||
}
|
||||
} else {
|
||||
Serial.println("Failed to decode Telemetry payload.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
Serial.print("Unhandled portnum "); Serial.print((int)data.portnum);
|
||||
Serial.println(", showing payload as hex:");
|
||||
printHex(data.payload.bytes, data.payload.size);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
delay(50);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -287,7 +287,7 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
|
||||
# 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
|
||||
|
||||
941
mesh_bot.py
941
mesh_bot.py
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,19 @@ import pickle # pip install pickle
|
||||
from modules.log import *
|
||||
import time
|
||||
|
||||
useSynchCompression = False
|
||||
|
||||
if useSynchCompression:
|
||||
import zlib
|
||||
from modules.system import send_raw_bytes
|
||||
|
||||
trap_list_bbs = ("bbslist", "bbspost", "bbsread", "bbsdelete", "bbshelp", "bbsinfo", "bbslink", "bbsack")
|
||||
|
||||
# global message list, later we will use a pickle on disk
|
||||
bbs_messages = []
|
||||
bbs_dm = []
|
||||
|
||||
|
||||
def load_bbsdb():
|
||||
global bbs_messages
|
||||
# load the bbs messages from the database file
|
||||
@@ -88,7 +95,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 = today.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
|
||||
|
||||
@@ -96,15 +107,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
|
||||
@@ -153,6 +166,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)])
|
||||
@@ -187,6 +208,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
|
||||
|
||||
@@ -231,7 +278,13 @@ def bbs_sync_posts(input, peerNode, RxNode):
|
||||
if messageID % 5 == 0:
|
||||
time.sleep(10 + responseDelay)
|
||||
logger.debug(f"System: Sending bbslink message {messageID} of {len(bbs_messages)} to peer " + str(peerNode))
|
||||
return f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]} @{fromNodeHex}"
|
||||
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))
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ import subprocess
|
||||
|
||||
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:
|
||||
@@ -29,21 +30,25 @@ 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():
|
||||
@@ -67,7 +72,6 @@ async def watch_file():
|
||||
def call_external_script(message, script="script/runShell.sh"):
|
||||
# Call an external script with the message as an argument this is a example only
|
||||
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)
|
||||
|
||||
@@ -77,45 +81,97 @@ def call_external_script(message, script="script/runShell.sh"):
|
||||
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')
|
||||
|
||||
# Use subprocess.run for better resource management
|
||||
result = subprocess.run(
|
||||
["bash", script_path, message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
return output
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error calling external script: {e}")
|
||||
return None
|
||||
|
||||
|
||||
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"
|
||||
|
||||
if enable_runShellCmd:
|
||||
# clean up the command input
|
||||
|
||||
# 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:]
|
||||
if command.startswith(" "):
|
||||
command = command[1:]
|
||||
command = command.strip()
|
||||
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 "x: invalid command format"
|
||||
# Run the shell command as a subprocess
|
||||
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()
|
||||
if output:
|
||||
return output
|
||||
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 "x: command is disabled"
|
||||
|
||||
return "x: command executed with no output"
|
||||
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()
|
||||
|
||||
@@ -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 message.lower() == "blackjack":
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,8 @@ lameJokes = [
|
||||
"Chuck Norris can do a wheelie on a unicycle.",
|
||||
"Chuck Norris can kill two stones with one bird."]
|
||||
|
||||
imtellingyourightnowiAmTellingYouRightNowThatMotherfErBackThereIsNotReal = ["🐦", "🦅", "🦆", "🦉", "🦜", "🐤", "🐥", "🐣", "🐔", "🐧", "🦚", "🦢", "🦩", "🦤", "🦃", "🐓"]
|
||||
|
||||
def tableOfContents():
|
||||
wordToEmojiMap = {
|
||||
'love': '❤️', 'heart': '❤️', 'happy': '😊', 'smile': '😊', 'sad': '😢', 'angry': '😠', 'mad': '😠', 'cry': '😢', 'laugh': '😂', 'funny': '😂', 'cool': '😎',
|
||||
@@ -166,10 +168,10 @@ def sendWithEmoji(message):
|
||||
i += 1
|
||||
return ' '.join(words)
|
||||
|
||||
def tell_joke(nodeID=0):
|
||||
def tell_joke(nodeID=0, vox=False):
|
||||
dadjoke = Dadjoke()
|
||||
try:
|
||||
if dad_jokes_emojiJokes:
|
||||
if dad_jokes_emojiJokes or vox:
|
||||
renderedLaugh = sendWithEmoji(dadjoke.joke)
|
||||
else:
|
||||
renderedLaugh = dadjoke.joke
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
165
modules/games/quiz.py
Normal file
165
modules/games/quiz.py
Normal file
@@ -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: <answer>" 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: <Answer>' '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()
|
||||
@@ -3,6 +3,8 @@
|
||||
# 2025
|
||||
from modules.log import *
|
||||
import random
|
||||
import time
|
||||
|
||||
# to molly and jake, I miss you both so much.
|
||||
|
||||
if disable_emojis_in_games:
|
||||
@@ -29,7 +31,7 @@ class TicTacToe:
|
||||
if id in self.game:
|
||||
games = self.game[id]["games"]
|
||||
won = self.game[id]["won"]
|
||||
if games > 0:
|
||||
if games > 3:
|
||||
if won / games >= 3.14159265358979323846: # win rate > pi
|
||||
ret += random.choice(positiveThoughts) + "\n"
|
||||
else:
|
||||
@@ -47,6 +49,10 @@ class TicTacToe:
|
||||
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"""
|
||||
@@ -90,19 +96,30 @@ class TicTacToe:
|
||||
return True
|
||||
|
||||
def bot_move(self, id):
|
||||
"""AI makes a move"""
|
||||
"""AI makes a move: tries to win, block, or pick random"""
|
||||
g = self.game[id]
|
||||
|
||||
# Simple AI: Try to win, block, or pick random
|
||||
move = self.find_winning_move(id, O) # Try to win
|
||||
if move == -1:
|
||||
move = self.find_winning_move(id, X) # Block player
|
||||
if move == -1:
|
||||
move = self.find_random_move(id) # Random move
|
||||
|
||||
board = g["board"]
|
||||
|
||||
# Try to win
|
||||
move = self.find_winning_move(id, O)
|
||||
if move != -1:
|
||||
g["board"][move] = O
|
||||
return move
|
||||
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"""
|
||||
@@ -117,12 +134,22 @@ class TicTacToe:
|
||||
return i
|
||||
board[i] = " "
|
||||
return -1
|
||||
|
||||
def find_random_move(self, id):
|
||||
"""Find a random empty position"""
|
||||
g = self.game[id]
|
||||
empty = [i for i in range(9) if g["board"][i] == " "]
|
||||
return random.choice(empty) if empty else -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"""
|
||||
@@ -156,7 +183,7 @@ class TicTacToe:
|
||||
if winner == X:
|
||||
g["won"] += 1
|
||||
return "🎉You won! (n)ew (e)nd"
|
||||
elif winner == X:
|
||||
elif winner == O:
|
||||
return "🤖Bot wins! (n)ew (e)nd"
|
||||
else:
|
||||
return "🤝Tie, The only winning move! (n)ew (e)nd"
|
||||
|
||||
@@ -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!"
|
||||
|
||||
182
modules/llm.py
182
modules/llm.py
@@ -48,7 +48,7 @@ meshBotAI = """
|
||||
PROMPT
|
||||
{input}
|
||||
|
||||
"""
|
||||
"""
|
||||
|
||||
if llmContext_fromGoogle:
|
||||
meshBotAI = meshBotAI + """
|
||||
@@ -76,6 +76,142 @@ if llmEnableHistory:
|
||||
|
||||
"""
|
||||
|
||||
# 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:
|
||||
# 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:
|
||||
return f"Error in calculation: {e}"
|
||||
|
||||
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:
|
||||
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:
|
||||
return [f"Error in Google search: {e}"]
|
||||
|
||||
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:
|
||||
googleSearch = search(input, advanced=True, num_results=num_results)
|
||||
if googleSearch:
|
||||
for result in googleSearch:
|
||||
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']
|
||||
return googleResults
|
||||
|
||||
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 <think> </think> tags to the response
|
||||
if "<think>" in result:
|
||||
result = result.split("</think>")[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 = []
|
||||
@@ -85,6 +221,10 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
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 "
|
||||
@@ -105,23 +245,7 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
antiFloodLLM.append(nodeID)
|
||||
|
||||
if llmContext_fromGoogle and not rawLLMQuery:
|
||||
# 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']
|
||||
googleResults = get_google_context(input, googleSearchResults)
|
||||
|
||||
history = llmChat_history.get(nodeID, ["", ""])
|
||||
|
||||
@@ -147,20 +271,11 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
|
||||
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
|
||||
# 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 <think> </think> tags to the response
|
||||
if "<think>" in result:
|
||||
result = result.split("</think>")[1]
|
||||
else:
|
||||
raise Exception(f"HTTP Error: {result.status_code}")
|
||||
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."
|
||||
|
||||
@@ -171,15 +286,8 @@ def llm_query(input, nodeID=0, location_name=None):
|
||||
#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 = requests.post(ollamaAPI, data=json.dumps(truncateQuery))
|
||||
if truncateResult.status_code == 200:
|
||||
truncate_json = truncateResult.json()
|
||||
result = truncate_json.get("response", "")
|
||||
truncateResult = send_ollama_query(truncateQuery)
|
||||
|
||||
else:
|
||||
#use the original result if truncation fails
|
||||
logger.warning("System: LLM Query: Truncation failed, using original response")
|
||||
|
||||
# cleanup for message output
|
||||
response = result.strip().replace('\n', ' ')
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert
|
||||
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")
|
||||
@@ -171,6 +172,7 @@ 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
|
||||
@@ -235,6 +237,7 @@ 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
|
||||
|
||||
@@ -292,9 +295,36 @@ 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",
|
||||
}
|
||||
# Single words (no spaces)
|
||||
word_replacements = {
|
||||
"monday": "Mon",
|
||||
"tuesday": "Tue",
|
||||
"wednesday": "Wed",
|
||||
@@ -310,6 +340,8 @@ def abbreviate_noaa(row):
|
||||
"south": "S",
|
||||
"east": "E",
|
||||
"west": "W",
|
||||
"accumulation": "accum",
|
||||
"visibility": "vis",
|
||||
"precipitation": "precip",
|
||||
"showers": "shwrs",
|
||||
"thunderstorms": "t-storms",
|
||||
@@ -331,21 +363,31 @@ 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",
|
||||
"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 float(lat) == 0 and float(lon) == 0 and not useDefaultLatLon:
|
||||
return NO_DATA_NOGPS
|
||||
else:
|
||||
@@ -422,6 +464,7 @@ 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
|
||||
@@ -609,54 +652,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 = ''
|
||||
@@ -820,6 +856,7 @@ 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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from modules.settings import *
|
||||
# if LOGGING_LEVEL is not set in settings.py, default to DEBUG
|
||||
if not LOGGING_LEVEL:
|
||||
|
||||
290
modules/radio.py
290
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
|
||||
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
|
||||
|
||||
95
modules/rss.py
Normal file
95
modules/rss.py
Normal file
@@ -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
|
||||
104
modules/scheduler.py
Normal file
104
modules/scheduler.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# modules/scheduler.py 2025 meshing-around
|
||||
import schedule
|
||||
from modules.log import logger
|
||||
from modules.system import send_message, BroadcastScheduler
|
||||
from modules.system import send_message
|
||||
# methods available for custom scheduler messages
|
||||
from mesh_bot import tell_joke, welcome_message, MOTD, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
|
||||
|
||||
async def setup_scheduler(
|
||||
schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface,
|
||||
schedulerValue, schedulerTime, schedulerInterval, logger, BroadcastScheduler
|
||||
):
|
||||
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
|
||||
if 'custom' not in schedulerValue:
|
||||
# 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}")
|
||||
else:
|
||||
# Default schedule if no valid configuration is provided
|
||||
|
||||
# custom scheduler job to run the schedule see examples below
|
||||
logger.debug(f"System: Starting the scheduler 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))
|
||||
|
||||
# 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))
|
||||
@@ -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()
|
||||
@@ -207,9 +224,10 @@ try:
|
||||
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)
|
||||
@@ -223,6 +241,9 @@ 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
|
||||
ollamaHostName = config['general'].get('ollamaHostName', 'http://localhost:11434') # default localhost
|
||||
llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m
|
||||
@@ -232,6 +253,11 @@ try:
|
||||
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)
|
||||
@@ -254,11 +280,14 @@ try:
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -341,25 +370,38 @@ 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['games'].getint('game_hop_limit', 5) # default 5 hops
|
||||
@@ -373,18 +415,24 @@ try:
|
||||
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', 200) # default 200
|
||||
maxBuffer = config['messagingSettings'].getint('maxBuffer', 200) # default 200 bytes
|
||||
enableHopLogs = config['messagingSettings'].getboolean('enableHopLogs', False) # default False
|
||||
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}")
|
||||
|
||||
194
modules/survey.py
Normal file
194
modules/survey.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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_name>_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
|
||||
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\nSend answer' or 'end'\n"
|
||||
msg += self.show_question(user_id)
|
||||
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:
|
||||
row = list(map(str, self.responses[user_id]['answers']))
|
||||
if surveyRecordID:
|
||||
row.insert(0, str(user_id))
|
||||
if surveyRecordLocation:
|
||||
location = self.responses[user_id].get('location')
|
||||
row.insert(1 if surveyRecordID else 0, str(location) if location is not None else "N/A")
|
||||
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 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']):
|
||||
self.responses[user_id]['answers'].append(str(option_index))
|
||||
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()
|
||||
|
||||
1448
modules/system.py
1448
modules/system.py
File diff suppressed because it is too large
Load Diff
126
modules/udp.py
Normal file
126
modules/udp.py
Normal file
@@ -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
|
||||
|
||||
|
||||
122
modules/wiki.py
Normal file
122
modules/wiki.py
Normal file
@@ -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
|
||||
22
pong_bot.py
22
pong_bot.py
@@ -353,7 +353,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:
|
||||
@@ -432,28 +431,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: S&F(messages command) 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/
|
||||
|
||||
@@ -3,9 +3,32 @@
|
||||
# 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:
|
||||
@@ -14,23 +37,82 @@ try:
|
||||
from modules.log import *
|
||||
from modules.system import *
|
||||
except Exception as e:
|
||||
print(f"Error importing modules run this program from the main program directory 'python3 script/addFav.py'")
|
||||
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:
|
||||
# compile the favorite list wich returns node,interface tuples
|
||||
favList = compileFavoriteList()
|
||||
logger.debug(f"addFav: Compiled favorite list:\n {favList}")
|
||||
# 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:
|
||||
handleFavoritNode(fav['deviceID'], fav['nodeID'], True)
|
||||
time.sleep(1)
|
||||
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:
|
||||
@@ -40,4 +122,9 @@ else:
|
||||
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)
|
||||
|
||||
104
script/configMerge.py
Normal file
104
script/configMerge.py
Normal file
@@ -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)
|
||||
42
update.sh
42
update.sh
@@ -24,6 +24,13 @@ if systemctl is-active --quiet mesh_bot_w3.service; then
|
||||
service_stopped=true
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# git pull with rebase to avoid unnecessary merge commits
|
||||
echo "Pulling latest changes from GitHub..."
|
||||
if ! git pull origin main --rebase; then
|
||||
@@ -37,22 +44,31 @@ if ! git pull origin main --rebase; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install or update dependencies
|
||||
echo "Installing or updating dependencies..."
|
||||
if pip install -r requirements.txt --upgrade 2>&1 | grep -q "externally-managed-environment"; then
|
||||
# if venv is found ask to run with launch.sh
|
||||
if [ -d "venv" ]; then
|
||||
echo "A virtual environment (venv) was found. run from inside venv"
|
||||
# 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
|
||||
read -p "Warning: You are in an externally managed environment. Do you want to continue with --break-system-packages? (y/n): " choice
|
||||
if [[ "$choice" == "y" || "$choice" == "Y" ]]; then
|
||||
pip install --break-system-packages -r requirements.txt --upgrade
|
||||
else
|
||||
echo "Update aborted due to dependency installation issue."
|
||||
fi
|
||||
echo "Configuration merge completed. Please review config_new.ini and ini_merge_log.txt."
|
||||
fi
|
||||
else
|
||||
echo "Dependencies installed or updated."
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user