mirror of
https://github.com/TheCommsChannel/TC2-BBS-mesh.git
synced 2025-04-21 15:46:45 -04:00
Merge 24f329d07e4785a7846ae7c6590a670ce6bef62e into 295fb35c92e376367b05a6429c481191baa23d17
This commit is contained in:
commit
e262987f7d
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,8 +1,8 @@
|
||||
__pycache__/
|
||||
bulletins.db
|
||||
js8call.db
|
||||
*.db
|
||||
venv/
|
||||
.venv
|
||||
.idea
|
||||
config.ini
|
||||
*/*/*.ini
|
||||
fortunes.txt
|
||||
|
@ -4,6 +4,7 @@ import random
|
||||
import time
|
||||
|
||||
from meshtastic import BROADCAST_NUM
|
||||
from plugins.weather_plugin.menu import get_weather_menu_text
|
||||
|
||||
from db_operations import (
|
||||
add_bulletin, add_mail, delete_mail,
|
||||
@ -24,6 +25,7 @@ config.read('config.ini')
|
||||
main_menu_items = config['menu']['main_menu_items'].split(',')
|
||||
bbs_menu_items = config['menu']['bbs_menu_items'].split(',')
|
||||
utilities_menu_items = config['menu']['utilities_menu_items'].split(',')
|
||||
plugin_menu_items = config['menu']['plugin_menu_items'].split(',')
|
||||
|
||||
|
||||
def build_menu(items, menu_name):
|
||||
@ -38,6 +40,8 @@ def build_menu(items, menu_name):
|
||||
menu_str += "[B]BS\n"
|
||||
elif item.strip() == 'U':
|
||||
menu_str += "[U]tilities\n"
|
||||
elif item.strip() == 'P':
|
||||
menu_str += "[P]lugins\n"
|
||||
elif item.strip() == 'X':
|
||||
menu_str += "E[X]IT\n"
|
||||
elif item.strip() == 'M':
|
||||
@ -52,6 +56,8 @@ def build_menu(items, menu_name):
|
||||
menu_str += "[F]ortune\n"
|
||||
elif item.strip() == 'W':
|
||||
menu_str += "[W]all of Shame\n"
|
||||
elif item.strip() == 'WE':
|
||||
menu_str += get_weather_menu_text()
|
||||
return menu_str
|
||||
|
||||
def handle_help_command(sender_id, interface, menu_name=None):
|
||||
@ -61,6 +67,8 @@ def handle_help_command(sender_id, interface, menu_name=None):
|
||||
response = build_menu(bbs_menu_items, "📰BBS Menu📰")
|
||||
elif menu_name == 'utilities':
|
||||
response = build_menu(utilities_menu_items, "🛠️Utilities Menu🛠️")
|
||||
elif menu_name == 'plugins':
|
||||
response = build_menu(plugin_menu_items, "🔌Plugins Menu🔌")
|
||||
else:
|
||||
update_user_state(sender_id, {'command': 'MAIN_MENU', 'step': 1}) # Reset to main menu state
|
||||
mail = get_mail(get_node_id_from_num(sender_id, interface))
|
||||
|
@ -81,6 +81,7 @@ bbs_menu_items = M, B, C, J, X
|
||||
# Remove any menu items from the list below that you want to exclude from the utilities menu
|
||||
utilities_menu_items = S, F, W, X
|
||||
|
||||
plugin_menu_items = WE
|
||||
|
||||
##########################
|
||||
#### JS8Call Settings ####
|
||||
|
@ -10,14 +10,21 @@ from command_handlers import (
|
||||
handle_check_bulletin_command, handle_read_bulletin_command, handle_read_channel_command,
|
||||
handle_post_channel_command, handle_list_channels_command, handle_quick_help_command
|
||||
)
|
||||
from plugins.weather_plugin.command_router import handle_weather_command, handle_weather_steps
|
||||
from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail, get_db_connection, add_channel
|
||||
from js8call_integration import handle_js8call_command, handle_js8call_steps, handle_group_message_selection
|
||||
from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message
|
||||
from utils import get_node_short_name, get_node_id_from_num, send_message, get_user_state, update_user_state
|
||||
|
||||
main_menu_handlers = {
|
||||
"q": handle_quick_help_command,
|
||||
"b": lambda sender_id, interface: handle_help_command(sender_id, interface, 'bbs'),
|
||||
"u": lambda sender_id, interface: handle_help_command(sender_id, interface, 'utilities'),
|
||||
"p": lambda sender_id, interface: handle_help_command(sender_id, interface, 'plugins'),
|
||||
"x": handle_help_command
|
||||
}
|
||||
|
||||
plugins_menu_handlers = {
|
||||
"we": handle_weather_command,
|
||||
"x": handle_help_command
|
||||
}
|
||||
|
||||
@ -109,6 +116,8 @@ def process_message(sender_id, message, interface, is_sync_message=False):
|
||||
handlers = bbs_menu_handlers
|
||||
elif menu_name == 'utilities':
|
||||
handlers = utilities_menu_handlers
|
||||
elif menu_name == 'plugins':
|
||||
handlers = plugins_menu_handlers
|
||||
else:
|
||||
handlers = main_menu_handlers
|
||||
elif state and state['command'] == 'BULLETIN_MENU':
|
||||
@ -166,6 +175,8 @@ def process_message(sender_id, message, interface, is_sync_message=False):
|
||||
handle_bb_steps(sender_id, message, 5, state, interface, bbs_nodes)
|
||||
elif command == 'BULLETIN_READ':
|
||||
handle_bb_steps(sender_id, message, 3, state, interface, bbs_nodes)
|
||||
elif command == 'WEATHER':
|
||||
handle_weather_steps(sender_id, message, step, interface)
|
||||
elif command == 'JS8CALL_MENU':
|
||||
handle_js8call_steps(sender_id, message, step, interface, state)
|
||||
elif command == 'GROUP_MESSAGES':
|
||||
|
0
plugins/__init__.py
Normal file
0
plugins/__init__.py
Normal file
83
plugins/weather_plugin/README.md
Normal file
83
plugins/weather_plugin/README.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Weather Plugin Documentation
|
||||
|
||||
## Overview
|
||||
The Weather Plugin provides weather information for the TC² BBS system using the OpenWeather API. It allows users to query current weather conditions by ZIP code. The plugin is accessible through the Plugins Menu in the BBS system.
|
||||
|
||||
## Structure
|
||||
```
|
||||
weather_plugin/
|
||||
├── __init__.py # Plugin exports and initialization
|
||||
├── weather.py # Core weather functionality and API interaction
|
||||
├── command_router.py # Command handling and user interaction
|
||||
├── menu.py # Menu text and options
|
||||
└── weather.ini # Configuration file
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### weather.py
|
||||
Core functionality:
|
||||
- OpenWeather API integration
|
||||
- Rate limiting (per minute and daily)
|
||||
- HTTP request handling with retries
|
||||
- Temperature conversion (Kelvin to Fahrenheit)
|
||||
- Weather data formatting
|
||||
|
||||
Key features:
|
||||
- Configurable API rate limits
|
||||
- Robust error handling
|
||||
- Request retries for reliability
|
||||
- Session management for connection reuse
|
||||
|
||||
### command_router.py
|
||||
User interaction:
|
||||
- Handles weather command from main menu
|
||||
- Processes ZIP code input
|
||||
- Manages user state
|
||||
- Returns formatted weather responses
|
||||
|
||||
### menu.py
|
||||
Menu integration:
|
||||
- Provides menu text for weather option
|
||||
- Defines menu item identifier
|
||||
|
||||
### Configuration (weather.ini)
|
||||
Settings:
|
||||
- OpenWeather API key
|
||||
- Rate limiting parameters:
|
||||
- Max calls per minute
|
||||
- Max calls per day
|
||||
- Plugin enabled/disabled flag
|
||||
|
||||
## Menu Integration
|
||||
The Weather Plugin is integrated into the TC² BBS system through the Plugins Menu:
|
||||
- Located in the main menu under [P]lugins
|
||||
- Identified by "WE" in the plugins menu
|
||||
- Configurable through the plugin_menu_items setting in config.ini
|
||||
|
||||
## Usage Flow
|
||||
1. User selects [P]lugins from main menu
|
||||
2. User selects "[WE]ather" from plugins menu
|
||||
3. System prompts for ZIP code
|
||||
4. User enters 5-digit ZIP code
|
||||
5. System checks rate limits
|
||||
6. Makes API request if within limits
|
||||
7. Returns formatted weather data:
|
||||
- Temperature
|
||||
- Feels like temperature
|
||||
- Humidity
|
||||
- Weather conditions
|
||||
- City name
|
||||
8. Returns to plugins menu after displaying weather
|
||||
|
||||
## Error Handling
|
||||
- Invalid ZIP codes
|
||||
- API rate limiting
|
||||
- Network timeouts
|
||||
- Missing configuration
|
||||
- API errors
|
||||
|
||||
## Dependencies
|
||||
- requests: HTTP client library
|
||||
- configparser: Configuration file handling
|
||||
- utils: BBS system utilities
|
9
plugins/weather_plugin/__init__.py
Normal file
9
plugins/weather_plugin/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from .command_router import handle_weather_command, handle_weather_steps
|
||||
from .menu import get_weather_menu_item, get_weather_menu_text
|
||||
|
||||
__all__ = [
|
||||
'handle_weather_command',
|
||||
'handle_weather_steps',
|
||||
'get_weather_menu_item',
|
||||
'get_weather_menu_text'
|
||||
]
|
29
plugins/weather_plugin/command_router.py
Normal file
29
plugins/weather_plugin/command_router.py
Normal file
@ -0,0 +1,29 @@
|
||||
from utils import send_message, update_user_state
|
||||
from .weather import get_weather_by_zip, format_weather_response
|
||||
|
||||
def handle_weather_command(sender_id, interface):
|
||||
"""Handle the weather command from the main menu."""
|
||||
response = "🌡️ Weather Service 🌡️\n\nEnter a ZIP code (89115) to get the current weather, or 'X' to exit:"
|
||||
send_message(response, sender_id, interface)
|
||||
update_user_state(sender_id, {'command': 'WEATHER', 'step': 1})
|
||||
|
||||
def handle_weather_steps(sender_id, message, step, interface):
|
||||
"""Handle the steps for getting weather information."""
|
||||
message = message.strip().upper()
|
||||
|
||||
if message == 'X':
|
||||
from command_handlers import handle_help_command
|
||||
handle_help_command(sender_id, interface)
|
||||
return
|
||||
|
||||
if step == 1:
|
||||
if not message.isdigit() or len(message) != 5:
|
||||
send_message("Please enter a valid 5-digit ZIP code, or 'X' to exit.", sender_id, interface)
|
||||
return
|
||||
|
||||
weather_data = get_weather_by_zip(message)
|
||||
response = format_weather_response(weather_data)
|
||||
send_message(response, sender_id, interface)
|
||||
|
||||
from command_handlers import handle_help_command
|
||||
handle_help_command(sender_id, interface)
|
7
plugins/weather_plugin/menu.py
Normal file
7
plugins/weather_plugin/menu.py
Normal file
@ -0,0 +1,7 @@
|
||||
def get_weather_menu_item():
|
||||
"""Return the weather menu item for the main menu."""
|
||||
return "WE"
|
||||
|
||||
def get_weather_menu_text():
|
||||
"""Return the weather menu text."""
|
||||
return "[WE]ather\n"
|
12
plugins/weather_plugin/weather.ini.example
Normal file
12
plugins/weather_plugin/weather.ini.example
Normal file
@ -0,0 +1,12 @@
|
||||
##########################
|
||||
#### OpenWeather API ####
|
||||
##########################
|
||||
# API key and rate limiting settings for the OpenWeather API
|
||||
# You can get one here.. https://openweathermap.org/api
|
||||
[openweather]
|
||||
api_key = YOUR API KEY HERE
|
||||
# Maximum number of API calls per minute (default: 30)
|
||||
max_calls_per_minute = 30
|
||||
# Maximum number of API calls per day (default: 500)
|
||||
max_calls_per_day = 500
|
||||
enabled = true
|
154
plugins/weather_plugin/weather.py
Normal file
154
plugins/weather_plugin/weather.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""
|
||||
Weather Module for TC² BBS
|
||||
|
||||
This module provides weather information using the OpenWeather API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from configparser import ConfigParser
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# OpenWeather API configuration
|
||||
config = ConfigParser()
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'weather.ini')
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
logging.error(f"Weather configuration file not found at {config_path}")
|
||||
raise FileNotFoundError(f"Weather configuration file not found at {config_path}")
|
||||
|
||||
try:
|
||||
config.read(config_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading weather configuration: {e}")
|
||||
raise
|
||||
|
||||
OPENWEATHER_API_KEY = config.get('openweather', 'api_key', fallback='')
|
||||
WEATHER_API_URL = "http://api.openweathermap.org/data/2.5/weather"
|
||||
|
||||
# Rate limiting configuration
|
||||
MAX_CALLS_PER_MINUTE = int(config.get('openweather', 'max_calls_per_minute', fallback='30'))
|
||||
MAX_CALLS_PER_DAY = int(config.get('openweather', 'max_calls_per_day', fallback='500'))
|
||||
|
||||
# Create a session object for better connection handling
|
||||
session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=3, # number of retries
|
||||
backoff_factor=1, # wait 1, 2, 4 seconds between retries
|
||||
status_forcelist=[500, 502, 503, 504] # HTTP status codes to retry on
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
# Rate limiting state
|
||||
minute_calls = 0
|
||||
day_calls = 0
|
||||
last_minute = datetime.now().replace(second=0, microsecond=0)
|
||||
last_day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def check_rate_limits():
|
||||
"""
|
||||
Check if we're within rate limits. Updates counters and resets them when needed.
|
||||
|
||||
Returns:
|
||||
bool: True if we can make a call, False if we've hit a limit
|
||||
"""
|
||||
global minute_calls, day_calls, last_minute, last_day
|
||||
|
||||
now = datetime.now()
|
||||
current_minute = now.replace(second=0, microsecond=0)
|
||||
current_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Reset minute counter if we're in a new minute
|
||||
if current_minute > last_minute:
|
||||
minute_calls = 0
|
||||
last_minute = current_minute
|
||||
|
||||
# Reset day counter if we're in a new day
|
||||
if current_day > last_day:
|
||||
day_calls = 0
|
||||
last_day = current_day
|
||||
|
||||
# Check if we've hit any limits
|
||||
if minute_calls >= MAX_CALLS_PER_MINUTE:
|
||||
logging.warning(f"Weather API minute limit reached ({MAX_CALLS_PER_MINUTE} calls)")
|
||||
return False
|
||||
|
||||
if day_calls >= MAX_CALLS_PER_DAY:
|
||||
logging.warning(f"Weather API daily limit reached ({MAX_CALLS_PER_DAY} calls)")
|
||||
return False
|
||||
|
||||
# We're good to make a call
|
||||
minute_calls += 1
|
||||
day_calls += 1
|
||||
return True
|
||||
|
||||
def kelvin_to_fahrenheit(kelvin):
|
||||
"""Convert Kelvin to Fahrenheit."""
|
||||
return (kelvin - 273.15) * 9/5 + 32
|
||||
|
||||
def get_weather_by_zip(zip_code, country_code='US'):
|
||||
"""
|
||||
Get weather information from OpenWeather API by zip code.
|
||||
|
||||
Args:
|
||||
zip_code: ZIP code to get weather for
|
||||
country_code: Country code (default: US)
|
||||
|
||||
Returns:
|
||||
dict: Weather data if successful, None if failed
|
||||
"""
|
||||
if not OPENWEATHER_API_KEY:
|
||||
logging.error("OpenWeather API key not configured")
|
||||
return None
|
||||
|
||||
# Check rate limits before making the call
|
||||
if not check_rate_limits():
|
||||
logging.error("Weather API rate limit exceeded")
|
||||
return None
|
||||
|
||||
try:
|
||||
params = {
|
||||
'zip': f"{zip_code},{country_code}",
|
||||
'appid': OPENWEATHER_API_KEY
|
||||
}
|
||||
response = session.get(WEATHER_API_URL, params=params, timeout=5)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.Timeout:
|
||||
logging.error(f"Weather API request timed out after 5 seconds for zip code {zip_code}")
|
||||
return {"error": "Request timed out - weather service is taking too long to respond"}
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Error fetching weather data: {e}")
|
||||
return None
|
||||
|
||||
def format_weather_response(weather_data):
|
||||
"""Format weather data into a user-friendly message."""
|
||||
if not weather_data:
|
||||
return "❌ Failed to get weather data."
|
||||
|
||||
if "error" in weather_data:
|
||||
return f"❌ {weather_data['error']}"
|
||||
|
||||
try:
|
||||
temp = kelvin_to_fahrenheit(weather_data['main']['temp'])
|
||||
feels_like = kelvin_to_fahrenheit(weather_data['main']['feels_like'])
|
||||
humidity = weather_data['main']['humidity']
|
||||
description = weather_data['weather'][0]['description'].capitalize()
|
||||
city = weather_data['name']
|
||||
|
||||
response = f"🌡️ Weather for {city} 🌡️\n\n"
|
||||
response += f"Temperature: {temp:.1f}°F\n"
|
||||
response += f"Feels like: {feels_like:.1f}°F\n"
|
||||
response += f"Humidity: {humidity}%\n"
|
||||
response += f"Conditions: {description}\n"
|
||||
|
||||
return response
|
||||
except (KeyError, IndexError) as e:
|
||||
logging.error(f"Error formatting weather data: {e}")
|
||||
return "❌ Error processing weather data"
|
124
test_client.py
Normal file
124
test_client.py
Normal file
@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = [
|
||||
# "meshtastic",
|
||||
# "pypubsub" ]
|
||||
# ///
|
||||
|
||||
# You can use Astral UV to use this script.
|
||||
# https://docs.astral.sh/uv/getting-started/installation/
|
||||
# Then simply run uv run test_client.py
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from command_handlers import handle_help_command
|
||||
from db_operations import get_db_connection, initialize_database
|
||||
from message_processing import process_message
|
||||
from utils import get_user_state, send_message
|
||||
|
||||
class MockInterface:
|
||||
def __init__(self):
|
||||
# Initialize with test nodes that match the BBS system structure
|
||||
self.nodes = {
|
||||
"!test1": {
|
||||
"num": 1234,
|
||||
"user": {"shortName": "TEST1", "longName": "Test Node 1"},
|
||||
"fromId": "!test1"
|
||||
},
|
||||
"!test2": {
|
||||
"num": 5678,
|
||||
"user": {"shortName": "TEST2", "longName": "Test Node 2"},
|
||||
"fromId": "!test2"
|
||||
}
|
||||
}
|
||||
# BBS nodes for syncing bulletins and mail
|
||||
self.bbs_nodes = ["!test1", "!test2"]
|
||||
self.messages_sent = []
|
||||
|
||||
# Initialize database for testing
|
||||
initialize_database()
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
|
||||
def sendText(self, text, destinationId, wantAck=True, wantResponse=False):
|
||||
message = {
|
||||
"text": text,
|
||||
"destinationId": destinationId,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"id": str(uuid.uuid4())
|
||||
}
|
||||
self.messages_sent.append(message)
|
||||
return self.MockResponse(message['id'])
|
||||
|
||||
def get_sent_messages(self):
|
||||
return self.messages_sent
|
||||
|
||||
def run_interactive_test():
|
||||
# Initialize test environment
|
||||
interface = MockInterface()
|
||||
test_user_id = "!test1"
|
||||
|
||||
def process_and_show_response(command):
|
||||
# Create a packet similar to what Meshtastic would send
|
||||
packet = {
|
||||
'from': interface.nodes[test_user_id]['num'], # The sender's node number
|
||||
'fromId': test_user_id, # The sender's node ID
|
||||
'decoded': {
|
||||
'portnum': 'TEXT_MESSAGE_APP',
|
||||
'payload': command.encode('utf-8')
|
||||
}
|
||||
}
|
||||
# Process the message using the packet
|
||||
process_message(packet['from'], command, interface)
|
||||
print("\nSystem Response:")
|
||||
for msg in interface.get_sent_messages():
|
||||
print(f"{msg['text']}")
|
||||
interface.messages_sent.clear()
|
||||
print("\n" + "-" * 50)
|
||||
|
||||
print("\n=== Interactive BBS Test Client ===")
|
||||
print("You are logged in as TEST1 (Node ID: !test1)")
|
||||
print("\nAvailable Commands:")
|
||||
print("- 'x' for main menu")
|
||||
print("- Quick commands:")
|
||||
print(" * SM,,recipient,subject,message - Send mail")
|
||||
print(" * PB,,board,subject,message - Post bulletin")
|
||||
print("- 'quit' to exit")
|
||||
print("-" * 50)
|
||||
|
||||
# Show initial menu
|
||||
process_and_show_response("x")
|
||||
|
||||
while True:
|
||||
try:
|
||||
command = input("\nEnter command: ").strip()
|
||||
if command.lower() == 'quit':
|
||||
print("\nExiting test client...")
|
||||
break
|
||||
|
||||
process_and_show_response(command)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nExiting test client...")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
try:
|
||||
run_interactive_test()
|
||||
except Exception as e:
|
||||
logging.error(f"Test failed with error: {str(e)}")
|
||||
print("\nTest client exited with error.")
|
Loading…
x
Reference in New Issue
Block a user