Merge remote-tracking branch 'origin/dev' into feat/region-code-warnings

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
agessaman committed 2026-09-20 10:43:24 -07:00
commit b5e61306b2
30 files changed
+3122 -382

No files matched your search

+62
View File
@@ -64,6 +64,41 @@ semantic versioning.
read-only beside the default, since they are the reason a channel can ignore
it.
- `[Hello_Command] include_sender` (default off) names the user the hello reply is
answering, so a busy channel can tell whose greeting the bot picked up (#292). The
mention takes the place of the random human descriptor, keeping the translated
sentence intact, and is dropped when it would push the reply past the channel body
budget. DMs are unaffected.
- `generate_website.py` accepts `--link-css URL` and `--embed-css FILE` to layer
custom CSS on top of the built-in style chosen with `--style` (#288). Embedded
CSS is appended to the page's `<style>` block, keeping the page a single file;
a linked stylesheet loads after it, so both override built-in rules of equal
specificity. Both flags also apply to `--sample` pages, and an unreadable
`--embed-css` file stops generation with an error. See
`docs/command-reference-website.md` for examples and the CSS class reference.
- Add the `contact` command (#293), which replies with the bot's own contact
card so a user can add the bot and DM it without waiting for an advert. Useful
for bots that do not advertise. Enabled by default; disable with
`[Contact_Command] enabled = false`.
- `generate_website.py` now lists the local commands installed under
`[Bot] local_dir_path` and omits commands disabled in the configuration file
(#287). It also reads the `<local_dir_path>/config.ini` overlay the way the
bot does, which is where the settings UI saves a local plugin's state, and it
resolves a command's section and legacy `enabled` aliases through the bot's
own helpers, so `[Jokes] joke_enabled = false` is honored rather than silently
ignored.
### Changed
- `meshcore` now requires 2.3.14 or newer. Before 2.3.13, `send_msg_with_retry`
reported ACKed DMs as failures: it subscribed to the ACK only after `send_msg`
returned, so an ACK queued right behind `MSG_SENT` was dispatched with no
listener, and each attempt accepted only its own ACK code, so a late ACK
answering an earlier attempt was ignored (meshcore_py#108). The bot logged
"no ACK received after retries" and skipped the delivery bookkeeping for
messages the recipient had in fact received.
### Fixed
- `outgoing_flood_scope_override = none` is now read as global flood on the
@@ -74,6 +109,30 @@ semantic versioning.
Bootstrap's light-theme text color on a dark background (about 1.3:1
contrast). The dark overrides set a background but not a color, so every
`.table-striped` page was affected.
- `[Joke_Command] joke_enabled` and `[DadJoke_Command] dadjoke_enabled` are now
listed in the shared legacy-alias table. Both commands accepted that spelling
at runtime through their own fallback, but the settings UI read only the
`[Jokes]` spelling, so a bot disabled the same-section way showed as enabled
on the plugin settings page.
- A region-scoped channel message now restores global flood even when
`set_flood_scope` raises. The restore only ran in the `finally` around the
send, so a set that raised left the device pinned to that region and every
later send — channel replies, DMs, scheduled sends — went out under it.
- A DM waiting for its ACK no longer holds the radio. Radio commands were
serialized per call, so a DM's retry loop kept every other command waiting
through all of its ACK timeouts (up to ~36 s with the default three attempts),
stalling channel replies, other DMs, and scheduled sends. The lock now covers
one frame and the radio's immediate reply, and ACK and remote-response waits
run outside it, so neighbor scope requests no longer stall replies either. A
region-scoped channel message now holds the radio from setting its flood scope
through restoring it, so no other send goes out under that scope.
- A radio connection is no longer accepted when every channel read times out or
returns no usable channel data (#266). Startup and reconnect now retry the
channel scan three times, keep an empty result out of the valid cache and
database, then fail the connection cleanly so the normal restart/reconnect
path can try again instead of running a bot that cannot route replies.
- Daily Weather Service forecasts now retry transient Open-Meteo failures at
5, 15, and 30 minutes after the original run (#264). HTTP 429, 500, 502, 503,
and 504 responses plus transport failures use one replaceable retry job,
@@ -423,6 +482,9 @@ semantic versioning.
### Added
- `docs/develop-command-scripts.md` created to assist developers with creating
commands that integrate correctly into the MeshCore Bot framework.
- Shlink is now supported as a URL shortener alongside v.gd / is.gd, selected with
`short_url_website_service = shlink` under `[External_Data]`. It authenticates with
`short_url_website_api_key` in an `X-Api-Key` header and needs `short_url_website`
+45 -39
View File
@@ -111,7 +111,7 @@ message_correlation_timeout = 10.0
enable_enhanced_correlation = true
# Bot node ID (leave empty for auto-assignment)
node_id =
node_id =
# Command prefix (optional). Single prefix (!), multiple decorative (!~.),
# or comma-separated (!, ~, .). First is shown in help/docs.
@@ -187,7 +187,7 @@ dm_flood_after = 2
# Timezone for bot operations
# Use standard timezone names (e.g., America/New_York, Europe/London, UTC)
# Leave empty to use system timezone
timezone =
timezone =
# Bot location for geographic proximity calculations and astronomical data
# Default latitude for bot location (decimal degrees)
@@ -425,13 +425,13 @@ auto_detect_language = false
# - Public keys MUST be exactly 64 hexadecimal characters (ed25519 format)
# - Invalid formats will be rejected with error logs
# - Empty or whitespace-only values disable admin access
# - Keys are case-insensitive (normalized to lowercase)
# - Keys are case-insensitive (normalized to lowercase)
#
# Format: comma-separated list of 64-character hex public keys (without spaces)
# Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d,1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
#
# IMPORTANT: Leave blank to disable all admin commands. Set your actual admin pubkey(s) here.
admin_pubkeys =
admin_pubkeys =
# Commands that require admin access (comma-separated)
# These commands will only work for users in the admin_pubkeys list
@@ -444,13 +444,13 @@ admin_commands = repeater,webviewer,reload,channelpause,neighbors
# Format: command_name = alternative_file_name
# The alternative_file_name should be the name of a Python file (without .py extension)
# in the modules/commands/alternatives/ directory
#
#
# Example: To use an alternative weather plugin for international users:
# wx = wx_international
#
#
# This will replace the default wx command with the plugin from
# modules/commands/alternatives/wx_international.py
#
#
# Note: The alternative plugin must have the same 'name' metadata as the command
# it's replacing, or the override will use the alternative plugin's name instead.
#
@@ -509,7 +509,7 @@ companion_min_inactive_days = 30
#
# To use a literal backslash + n, use \\n (double backslash + n)
# Other escape sequences: \t (tab), \r (carriage return), \\ (literal backslash)
#
#
# Available placeholders (mesh network info - same as Scheduled_Messages):
# Total counts (ever heard):
# {total_contacts} - Total number of contacts ever heard
@@ -517,23 +517,23 @@ companion_min_inactive_days = 30
# {total_companions} - Total number of companion devices ever heard
# {total_roomservers} - Total number of roomserver devices ever heard
# {total_sensors} - Total number of sensor devices ever heard
#
#
# Recent activity:
# {recent_activity_24h} - Number of unique users active in last 24 hours
#
#
# Active in last 30 days (last_heard):
# {total_contacts_30d} - Total contacts active (last_heard) in last 30 days
# {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days
# {total_companions_30d} - Total companions active (last_heard) in last 30 days
# {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days
# {total_sensors_30d} - Total sensors active (last_heard) in last 30 days
#
#
# New devices (first heard in last 7 days):
# {new_companions_7d} - New companion devices first heard in last 7 days
# {new_repeaters_7d} - New repeater devices first heard in last 7 days
# {new_roomservers_7d} - New roomserver devices first heard in last 7 days
# {new_sensors_7d} - New sensor devices first heard in last 7 days
#
#
# Legacy placeholders (for backward compatibility):
# {repeaters} - Same as {total_repeaters}
# {companions} - Same as {total_companions}
@@ -608,7 +608,7 @@ category.funfact = fun
#
# Newlines: use \n in the message for a line break (e.g. general:Line one\nLine two).
# Literal backslash: use \\n for backslash+n; \\t for tab.
#
#
# Command output placeholder:
#
# {cmd:<command> [args]} runs a bot command and substitutes its reply text, so any
@@ -640,34 +640,34 @@ category.funfact = fun
# fires, the placeholder expands to nothing that round.
#
# Available placeholders for mesh network information:
#
#
# Total counts (ever heard):
# {total_contacts} - Total number of contacts ever heard
# {total_repeaters} - Total number of repeater devices ever heard
# {total_companions} - Total number of companion devices ever heard
# {total_roomservers} - Total number of roomserver devices ever heard
# {total_sensors} - Total number of sensor devices ever heard
#
#
# Recent activity:
# {recent_activity_24h} - Number of unique users active in last 24 hours
#
#
# Active in last 30 days (last_heard):
# {total_contacts_30d} - Total contacts active (last_heard) in last 30 days
# {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days
# {total_companions_30d} - Total companions active (last_heard) in last 30 days
# {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days
# {total_sensors_30d} - Total sensors active (last_heard) in last 30 days
#
#
# New devices (first heard in last 7 days):
# {new_companions_7d} - New companion devices first heard in last 7 days
# {new_repeaters_7d} - New repeater devices first heard in last 7 days
# {new_roomservers_7d} - New roomserver devices first heard in last 7 days
# {new_sensors_7d} - New sensor devices first heard in last 7 days
#
#
# Legacy placeholders (for backward compatibility):
# {repeaters} - Same as {total_repeaters}
# {companions} - Same as {total_companions}
#
#
# Example with placeholders (cron):
# 0 8 * * * = Public:Good morning! Network: {total_contacts} total ({total_repeaters} repeaters, {total_companions} companions). {new_repeaters_7d} new repeaters, {new_companions_7d} new companions in last 7d. {recent_activity_24h} active in 24h.
# Example with 30-day active devices and new devices in 7d:
@@ -735,33 +735,33 @@ short_url_website_service = gd
short_url_website = https://v.gd
# API key. Optional for gd (unused for public v.gd/is.gd, appended as ?key= for
# alternate hosts). Required for shlink, where it is sent as an X-Api-Key header.
short_url_website_api_key =
short_url_website_api_key =
# Weather API key (future feature)
weather_api_key =
weather_api_key =
# Weather update interval in seconds (future feature)
weather_update_interval = 3600
# Tide API key (future feature)
tide_api_key =
tide_api_key =
# Tide update interval in seconds (future feature)
tide_update_interval = 1800
# N2YO API key for satellite pass information
# Get free key at: https://www.n2yo.com/login/
n2yo_api_key =
n2yo_api_key =
# AirNow API key for AQI data
# Get free key at: https://docs.airnowapi.org/
airnow_api_key =
airnow_api_key =
# Forecast.Solar API key for solar forecast data
# Get key at: https://forecast.solar/ (free tier works without key, paid tier for 3+ day forecasts)
# Free tier: 2-day forecast, 1-hour resolution
# Paid tier (14 EUR/year): 3-6 day forecast, 15-30 minute resolution
forecast_solar_api_key =
forecast_solar_api_key =
# Optional external repeater-prefix API for the prefix command.
#
@@ -776,7 +776,7 @@ forecast_solar_api_key =
# Fetched with a plain GET, must answer HTTP 200 with that JSON, 10s timeout.
# "prefix" is upper-cased by the bot; "node_count" is an int; "node_names" a list.
# Responses are cached for repeater_prefix_cache_hours below.
repeater_prefix_api_url =
repeater_prefix_api_url =
# Repeater prefix cache duration in hours
# How long to cache prefix data before refreshing from API
@@ -1162,7 +1162,7 @@ path_selection_preset = balanced
# Basic Settings
# Geographic proximity calculation method
# simple: Use proximity to bot location
# simple: Use proximity to bot location
# path: Use proximity to previous/next nodes in the path for more realistic routing (default)
proximity_method = path
@@ -1411,7 +1411,7 @@ enabled = false
# Comma-separated list to restrict to specific channels (only greeter command works there)
# Example: channels = general,welcome,newbies
# If not specified, uses the channels from [Channels] monitor_channels setting
# channels =
# channels =
# Greeting message template (default for all channels)
# Available fields: {sender} - the user's name/ID
@@ -1429,7 +1429,7 @@ greeting_message = Welcome to the mesh, @[{sender}]!
# channel name. (So avoid text like ", wx: sunny" if wx is also a channel.)
# Multi-part greetings are supported per channel using pipe (|) separator
# Leave empty to use greeting_message for all channels
channel_greetings =
channel_greetings =
# Per-channel greetings (tracking behavior)
# false: Greet each user only once globally (default - user gets one greeting total)
@@ -1514,7 +1514,7 @@ enabled = false
# Format: comma-separated list of 64-character hex public keys (without spaces)
# Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d
# Leave empty to only use Admin_ACL members
announcements_acl =
announcements_acl =
# Default channel for announcements when no channel is specified
# Announcements will be sent to this channel if no channel is provided
@@ -1612,10 +1612,16 @@ enabled = true
# channels =
[Hello_Command]
enabled = true
# Name the sender in the reply (channel messages only), so a busy channel
# can tell whose greeting was answered.
include_sender = false
# channels =
[Dice_Command]
enabled = true
# channels =
[Contact_Command]
enabled = true
# channels =
[Cmd_Command]
enabled = true
# Optional: override `cmd` output with a link to your full docs page
@@ -1862,7 +1868,7 @@ mesh_graph_cache_seconds = 30
#
# Note: Only hashtag channels work here - custom channels with private keys
# must be added to the radio itself.
decode_hashtag_channels =
decode_hashtag_channels =
####################################################################################################
# #
@@ -1878,7 +1884,7 @@ enabled = false
# Output file for packet data (optional)
# Leave empty to disable file output
# Packets will be written as JSON lines
output_file =
output_file =
# Verbose output (show JSON packet data in logs)
# true: Show packet data in logs
@@ -2010,7 +2016,7 @@ neighbors_self_scopes =
owner_public_key =
# Owner email address
owner_email =
owner_email =
# Private key file path for auth token generation (fallback if device signing unavailable)
# Optional - on-device signing is preferred
@@ -2018,7 +2024,7 @@ owner_email =
# Required only if device doesn't support on-device signing or auth_token_method = python
# Note: If not provided and auth_token_method = python, the service will attempt to fetch
# the private key from the device automatically
private_key_path =
private_key_path =
# Auth token signing method
# device: Try on-device signing first, fallback to Python signing (default, recommended)
@@ -2114,8 +2120,8 @@ mqtt2_token_audience = mqtt-eu-v1.letsmesh.net
mqtt2_topic_status = meshcore/{IATA}/{PUBLIC_KEY}/status
mqtt2_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets
mqtt2_websocket_path = /mqtt
mqtt2_client_id =
mqtt2_upload_packet_types =
mqtt2_client_id =
mqtt2_upload_packet_types =
# Stats and status publishing
# Enable stats in status messages
@@ -2152,7 +2158,7 @@ api_url = https://map.meshcore.dev/api/v1/uploader/node
# If not provided, the service will attempt to fetch the private key from the device
# Supports 64-byte orlp format (128 hex chars) or 32-byte seed (64 hex chars)
# Required only if device doesn't support private key export
private_key_path =
private_key_path =
# Minimum time between re-uploads of same node (seconds)
# Prevents uploading the same node too frequently to avoid API spam
@@ -2184,10 +2190,10 @@ weather_alarm = 6:00
# Bot position for weather forecasts and alerts
# Latitude in decimal degrees
my_position_lat =
my_position_lat =
# Longitude in decimal degrees
my_position_lon =
my_position_lon =
# Channel for daily weather forecasts
# Weather forecasts will be sent to this channel
+427 -2
View File
@@ -10,6 +10,16 @@ python generate_website.py [config.ini]
This reads your config (default: `config.ini`), loads your commands and channels, and writes **`website/index.html`** in the same directory as the config file. Upload that file (and the directory if you use assets) to your web host.
## Which commands get listed
The page lists what your bot actually answers:
- **Local commands are included.** Any command installed under `[Bot] local_dir_path` (default `local/commands`) appears alongside the built-in ones.
- **Disabled commands are omitted.** A command turned off with `enabled = false` in its `[*_Command]` section is left out, including the legacy spellings such as `[Jokes] joke_enabled`.
- **The local config overlay is read.** Settings saved from the web viewer's plugin settings page land in `<local_dir_path>/config.ini`, and the generator reads that on top of your base config just as the bot does.
Admin-only, hidden, and keyword-less commands are never listed.
## Choose a style
The `--style` option selects a theme (colors, typography, layout). Default is `default` (modern dark). To see all themes:
@@ -27,6 +37,86 @@ python generate_website.py config.ini --style terminal
Available styles include: **default** (modern dark), **minimalist** (light, clean), **terminal** (green/amber on black), **glass** (glassmorphism), **neon** (cyberpunk), **brutalist** (bold, high contrast), **gradient** (colorful gradients), **pixel** (retro gaming). Run `--list-styles` for the full list and short descriptions.
## Add custom CSS
You can layer your own CSS on top of the built-in style chosen with `--style` (`default` if you don't pass one). The built-in CSS always loads first, so you only write the rules you want to change, and your rules win over built-in rules of equal specificity.
### Link to external CSS
Use `--link-css` to reference a stylesheet hosted on your server or a CDN:
```bash
python generate_website.py config.ini --link-css https://example.com/my-custom.css
python generate_website.py config.ini --style minimalist --link-css https://example.com/overrides.css
```
The `<link>` tag is placed after the built-in `<style>` block. A relative path such as `--link-css overrides.css` is resolved by the browser against `website/index.html`, so upload the stylesheet next to it.
### Embed CSS from a file
Use `--embed-css` to inline CSS from a local file into the generated HTML:
```bash
python generate_website.py config.ini --embed-css tweaks.css
python generate_website.py config.ini --style terminal --embed-css tweaks.css
```
The file's contents are appended to the built-in `<style>` block, so the page stays a single self-contained file. The path is relative to the directory you run the script from. If the file can't be read, the script exits with an error instead of generating the page.
This is useful for:
- Small tweaks to a built-in style (colors, fonts, spacing)
- Offline deployments, or hosts where you can only upload one file
### Using both
You can pass `--embed-css` and `--link-css` together. The page loads the built-in style, then the embedded CSS, then the linked stylesheet, so the linked stylesheet wins any conflict.
### Example: Custom color scheme
Create a file `my-colors.css`:
```css
:root {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--accent-blue: #0f3460;
--accent-cyan: #e94560;
--text-primary: #eee;
}
```
Then generate with:
```bash
python generate_website.py config.ini --style default --embed-css my-colors.css
```
This keeps all the layout and styling from the default theme but applies your custom color palette.
### Example workflow: Fine-tuning a built-in style
1. Generate with a built-in style you like:
```bash
python generate_website.py config.ini --style minimalist
```
2. Inspect the output and identify what you want to change (e.g., "I want blue accents instead of the default colors")
3. Create a small override file `tweaks.css` with just the changes:
```css
:root {
--accent-blue: #2563eb;
--accent-cyan: #0891b2;
}
```
4. Regenerate with your tweaks:
```bash
python generate_website.py config.ini --style minimalist --embed-css tweaks.css
```
This approach is much easier than writing CSS from scratch, since you only override specific properties while keeping all the responsive design, layouts, and other styling intact.
## Preview all styles
To generate a sample page for every style plus an index that links to them (useful to pick a theme):
@@ -35,7 +125,7 @@ To generate a sample page for every style plus an index that links to them (usef
python generate_website.py config.ini --sample
```
Output goes to `website/` with one HTML file per style and an `index.html` you can open locally.
Output goes to `website/` with one HTML file per style and an `index.html` you can open locally. `--link-css` and `--embed-css` apply to every sample page, so you can preview your overrides against each style.
## Custom title and intro
@@ -51,4 +141,339 @@ If omitted, the script uses the bot name and a default intro.
## Uploading
The script produces a self-contained HTML file (with embedded CSS). Upload `website/index.html` to any static host (e.g. GitHub Pages, Netlify, or your group's web server). No server-side processing is required.
The script produces a self-contained HTML file (with embedded CSS). Upload `website/index.html` to any static host (e.g. GitHub Pages, Netlify, or your group's web server). No server-side processing is required. If you used `--link-css` with a relative path, upload that stylesheet alongside `index.html`.
## CSS architecture and customization
The generated HTML includes embedded CSS that uses a **CSS custom properties (variables) system** for easy theming. This section documents the CSS architecture and how to create your own custom styles.
### CSS structure overview
The stylesheet is organized into several sections:
1. **CSS Custom Properties (`:root`)** - Theme variables for colors, fonts, spacing
2. **Reset & Base Styles** - Global resets and base element styles
3. **Layout Components** - Container, grid, sidebar navigation
4. **Header & Introduction** - Title area and intro text
5. **Command Cards** - Individual command display cards
6. **Channel Cards** - Channel listing cards
7. **Mobile Responsive** - Breakpoints and mobile menu
8. **Theme Overrides** - Style-specific customizations
### CSS custom properties (theme variables)
Most colors are defined as CSS custom properties in the `:root` selector, so you can retheme a page without touching the rest of the CSS. A few effects (background gradients, badge tints) and some style-specific overrides use literal colors instead.
#### Color variables
```css
:root {
/* Backgrounds */
--bg-primary: #0a0e14; /* Main page background */
--bg-secondary: #111820; /* Secondary background (usage boxes) */
--bg-card: #151c25; /* Card backgrounds */
--bg-card-hover: #1a232e; /* Card hover state */
/* Accent Colors */
--accent-blue: #00d4ff; /* Primary accent (command names, links) */
--accent-cyan: #00ffc8; /* Secondary accent (keywords, highlights) */
--accent-orange: #ff8a00; /* Parameter names */
--accent-purple: #a855f7; /* Only used by the neon style */
--accent-red: #ff4757; /* Not currently used */
--accent-yellow: #ffd700; /* Only used by the terminal style */
/* Text Colors */
--text-primary: #e8edf4; /* Main text color */
--text-secondary: #8892a4; /* Secondary text (descriptions) */
--text-muted: #6b7280; /* Muted text (labels, footers) */
/* Borders & Effects */
--border-subtle: rgba(255,255,255,0.06); /* Subtle borders */
--glow-blue: rgba(0, 212, 255, 0.15); /* Only used by the neon style */
--glow-cyan: rgba(0, 255, 200, 0.1); /* Not currently used */
}
```
#### Typography variables
Fonts are loaded via Google Fonts and applied through the CSS. The default theme uses:
- **Outfit** - Sans-serif for body text and headings
- **JetBrains Mono** - Monospace for code, keywords, and technical text
### HTML structure and CSS classes
#### Page layout
```html
<body>
<div class="atmosphere"></div> <!-- Background gradient effect -->
<div class="grid-overlay"></div> <!-- Subtle grid pattern -->
<div class="sidebar-overlay"></div> <!-- Mobile menu overlay -->
<button class="mobile-menu-toggle"> <!-- Mobile hamburger menu -->
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
<nav class="sidebar-nav"> <!-- Left sidebar navigation -->
<div class="nav-header">
<h3>Navigation</h3>
</div>
<ul class="nav-list">
<li class="nav-section-header">Commands</li>
<ul class="nav-sublist">
<li><a href="#..." class="nav-link nav-sublink">Category</a></li>
</ul>
</ul>
</nav>
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>Bot Name</h1>
</div>
<div class="intro">Introduction text...</div>
</div>
</header>
<main>
<!-- Command categories -->
<div class="category-section" id="commands-category">
<h2 class="category-title">
<a href="#..." class="anchor-link">Category Name</a>
</h2>
<div class="commands-grid">
<!-- Command cards go here -->
</div>
</div>
<!-- Channels section -->
<div class="category-section" id="channels">
<h2 class="category-title">
<a href="#channels" class="anchor-link">Available Channels</a>
</h2>
<p class="channels-intro">...</p>
<div class="channel-category">
<h3 class="channel-category-title">...</h3>
<div class="channels-grid">
<!-- Channel cards go here -->
</div>
</div>
</div>
</main>
<footer>
<p>Generated command reference...</p>
</footer>
</div>
</div>
</body>
```
#### Command card structure
```html
<div class="command-card">
<div class="command-header">
<h3 class="command-name">commandname</h3>
<div class="command-keywords">
<span class="keyword-badge">alias1</span>
<span class="keyword-badge">alias2</span>
<span class="keyword-badge keyword-expand" data-hidden="...">+3 more</span>
</div>
</div>
<p class="command-description">Command description text</p>
<div class="command-usage">
<code>!commandname [args]</code>
</div>
<div class="command-params">
<div class="params-header">Parameters:</div>
<div class="param-item">
<span class="param-name">param1</span>
<span class="param-desc">Description</span>
</div>
</div>
<div class="command-subcommands">
<div class="subcommands-header">Sub-commands:</div>
<div class="subcommand-item">
<span class="subcommand-name">subcmd</span>
<span class="subcommand-desc">Description</span>
</div>
</div>
<div class="command-channels">Channel: #specific-channel</div>
</div>
```
#### Channel card structure
```html
<div class="channel-card">
<div class="channel-name">#channel-name</div>
<div class="channel-description">Channel description text</div>
</div>
```
### Key CSS classes reference
#### Layout classes
- `.container` - Main content wrapper with grid layout
- `.sidebar-nav` - Left sidebar navigation (sticky on desktop)
- `.main-content` - Right side main content area
- `.mobile-menu-toggle` - Hamburger menu button (mobile only)
- `.sidebar-overlay` - Dark overlay when mobile menu is open
#### Navigation classes
- `.nav-header` - Navigation section header
- `.nav-list` - Main navigation list
- `.nav-section-header` - Section divider in nav (e.g., "Commands", "Channels")
- `.nav-sublist` - Nested navigation list
- `.nav-link` - Navigation link item
- `.nav-sublink` - Nested/indented navigation link
#### Header classes
- `.header-content` - Header wrapper with background and border
- `.header-title` - Title container
- `.intro` - Introduction text paragraph
- `.channel-highlight` - Highlighted channel names in intro text
#### Content organization classes
- `.category-section` - Wrapper for each command category
- `.category-title` - Category heading (e.g., "Weather Commands")
- `.anchor-link` - Linkable heading anchor
- `.commands-grid` - CSS Grid container for command cards
- `.channels-grid` - CSS Grid container for channel cards
#### Command card classes
- `.command-card` - Individual command card container
- `.command-header` - Command name and keywords section
- `.command-name` - Command name heading
- `.command-keywords` - Container for keyword badges
- `.keyword-badge` - Individual keyword/alias badge
- `.keyword-expand` - "+X more" expandable badge
- `.keyword-hidden` - Hidden keywords (shown on expand)
- `.command-description` - Command description paragraph
- `.command-usage` - Usage syntax box
- `.command-params` - Parameters section wrapper
- `.params-header` - "Parameters:" label
- `.param-item` - Individual parameter row
- `.param-name` - Parameter name
- `.param-desc` - Parameter description
- `.command-subcommands` - Sub-commands section wrapper
- `.subcommands-header` - "Sub-commands:" label
- `.subcommand-item` - Individual sub-command row
- `.subcommand-name` - Sub-command name
- `.subcommand-desc` - Sub-command description
- `.command-channels` - Channel restriction notice
#### Channel card classes
- `.channel-category` - Channel category wrapper
- `.channel-category-title` - Channel category heading
- `.channels-intro` - Introduction text for channels section
- `.channel-card` - Individual channel card
- `.channel-name` - Channel name (e.g., "#general")
- `.channel-description` - Channel description text
#### Utility classes
- `.atmosphere` - Background gradient effect layer
- `.grid-overlay` - Subtle grid pattern overlay
- `.hamburger` - Hamburger menu bar element
### Responsive breakpoints
The CSS includes two main responsive breakpoints:
- **1200px** - Switches to mobile layout, shows hamburger menu, sidebar becomes slide-out
- **768px** - Further mobile optimizations for small screens
Mobile behavior:
- Sidebar slides in from left when hamburger is clicked
- Overlay appears over main content
- Grid layouts adjust to single column
- Font sizes scale down slightly
### Tips for custom styles
1. **Start with variables** - Most visual changes can be achieved by only changing the CSS custom properties in `:root`
2. **Use the sample generator** - Run `--sample` with your `--embed-css` or `--link-css` flag to see your overrides on every built-in style
3. **Respect the structure** - The HTML structure and class names are semantic and should remain consistent
4. **Test mobile** - Always check your custom styles at mobile breakpoints (< 1200px, < 768px)
5. **Consider accessibility** - Ensure sufficient color contrast (WCAG AA: 4.5:1 for normal text, 3:1 for large text)
6. **Use monospace for code** - Keep `JetBrains Mono` or similar for `.keyword-badge`, `.command-usage code`, `.param-name`, etc.
7. **Preserve hover states** - Interactive elements should have clear hover/focus states for usability
8. **Match `!important` where needed** - The base CSS uses `!important` on many mobile-layout rules, and the brutalist, pixel, and minimalist styles use it heavily in their overrides. A custom rule only beats one of those if it's also `!important`.
9. **Some styles hard-code colors** - The gradient and brutalist styles set many colors directly instead of through the variables, so changing only the `:root` variables has less effect on them.
### Example style variations
#### Dark high-contrast
```css
:root {
--bg-primary: #000000;
--bg-card: #0a0a0a;
--accent-blue: #00ffff;
--accent-cyan: #00ff00;
--text-primary: #ffffff;
--border-subtle: rgba(255,255,255,0.2);
}
```
#### Light professional
```css
:root {
--bg-primary: #ffffff;
--bg-card: #f5f5f5;
--accent-blue: #0066cc;
--accent-cyan: #0088cc;
--text-primary: #333333;
--text-secondary: #666666;
--border-subtle: rgba(0,0,0,0.1);
}
```
#### Warm earth tones
```css
:root {
--bg-primary: #2c2416;
--bg-card: #3d2f1f;
--accent-blue: #d4a574;
--accent-cyan: #c9a961;
--accent-orange: #e6925b;
--text-primary: #f4e8d8;
--text-secondary: #b89968;
}
```
### JavaScript functionality
The generated HTML includes minimal JavaScript for:
1. **Mobile menu toggle** - Shows/hides sidebar navigation on mobile
2. **Keyword expansion** - Expands "+X more" keyword badges when clicked
Smooth scrolling to anchors comes from the CSS `scroll-behavior` property, not JavaScript.
These behaviors are built-in and don't require customization for basic styling changes.
+13
View File
@@ -97,6 +97,19 @@ cmd
---
### `contact`
Share the bot's own contact card so you can add it and send DMs without waiting for an advert.
**Usage:**
```
contact
```
**Response:** A clickable contact card containing the bot's public key and device name.
---
### `version`
Show the bot's current software version.
File diff suppressed because it is too large. Load diff
+1
View File
@@ -33,6 +33,7 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services
| [Feed Management](FEEDS.md) | RSS/REST feeds and posting to channels |
| [Web Viewer](web-viewer.md) | Web-based data viewer and API |
| [Custom command website](command-reference-website.md) | Generate an HTML command reference for your users |
| [Develop commands](develop-command-scripts.md) | Guide for developing bot commands |
## Service Plugins
+13 -17
View File
@@ -481,24 +481,20 @@ more minute" would be followed by fourteen more minutes of personal cooldown.
### Region scopes are opt-in, and why
`neighbors_collect_scopes` additionally asks each neighbour for its region scopes.
It defaults to **false** for two reasons specific to running inside the bot:
It defaults to **false** for a reason specific to running inside the bot:
1. **It stalls bot replies.** Every bot radio command is serialised through one lock
(`modules/core.py` `_SerializedCommands`), and `req_regions_sync` waits for its
reply *inside* the call — so one request holds the radio for up to ~25 s. With 32
neighbours the bot's own messages stall in bursts for minutes.
2. **It mutates device contact state.** The zero-hop probe relies on the neighbour
*not* being a known contact. The bot does track contacts, and for a repeater with
no stored path the meshcore library reaches zero-hop by calling
`change_contact_path()` and then `reset_path()` — temporarily rewriting that
contact's path on the device. Those two calls are not paired by a
`try`/`finally` upstream, and one error path returns between them, so a request
cut short — or one whose path change was applied but not acknowledged — would
leave the contact pinned to zero-hop and every later message to it sent
direct-only. `modules/neighbors_discovery.py` restores the path itself in each
of those cases, and warns if the device rejects the restore (which it reports
as an error event rather than an exception), since that contact's routing is
then wrong until something else fixes it.
- **It mutates device contact state.** The zero-hop probe relies on the neighbour
*not* being a known contact. The bot does track contacts, and for a repeater with
no stored path the meshcore library reaches zero-hop by calling
`change_contact_path()` and then `reset_path()` — temporarily rewriting that
contact's path on the device. Those two calls are not paired by a
`try`/`finally` upstream, and one error path returns between them, so a request
cut short — or one whose path change was applied but not acknowledged — would
leave the contact pinned to zero-hop and every later message to it sent
direct-only. `modules/neighbors_discovery.py` restores the path itself in each
of those cases, and warns if the device rejects the restore (which it reports
as an error event rather than an exception), since that contact's routing is
then wrong until something else fixes it.
With it off, the snapshot reports every neighbour it heard with empty `scopes` and
`status: responded`. Enable it on a bench radio first.
+260 -151
View File
@@ -24,6 +24,7 @@ try:
from modules.config_validation import strip_optional_quotes
from modules.db_manager import DBManager
from modules.plugin_loader import PluginLoader
from modules.settings_schema import command_section_name, read_enabled
from modules.utils import resolve_path
except ImportError as e:
print("Error: Missing required dependencies.")
@@ -39,10 +40,11 @@ except ImportError as e:
class MinimalBot:
"""Minimal bot mock for plugin loading without full bot initialization"""
def __init__(self, config, logger, db_manager=None):
def __init__(self, config, logger, db_manager=None, bot_root=None):
self.config = config
self.logger = logger
self.db_manager = db_manager
self.bot_root = bot_root
# Dummy translator
class DummyTranslator:
@@ -920,11 +922,25 @@ def setup_logging():
def read_config(config_file: str = "config.ini") -> configparser.ConfigParser:
"""Read and parse config.ini file"""
"""Read config.ini, then overlay `<local_dir_path>/config.ini` if present.
Mirrors `Bot._read_config_snapshot` in modules/core.py. The overlay is where
the settings UI writes a local plugin's settings, so without it the
generated page would advertise commands the running bot refuses.
"""
config = configparser.ConfigParser()
if not os.path.exists(config_file):
raise FileNotFoundError(f"Config file not found: {config_file}")
config.read(config_file)
bot_root = os.path.dirname(os.path.abspath(config_file))
local_config = os.path.join(
resolve_path(config.get('Bot', 'local_dir_path', fallback='local'), bot_root),
'config.ini',
)
if os.path.isfile(local_config):
config.read(local_config)
return config
@@ -1144,8 +1160,49 @@ def get_command_popularity(db_path: Optional[str], commands: dict[str, Any]) ->
return popularity
def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict[str, Any]:
"""Filter out admin and hidden commands"""
def resolve_local_commands_dir(
config: configparser.ConfigParser, bot_root: str, logger: logging.Logger
) -> Optional[str]:
"""Resolve the local commands directory, or None when it does not exist.
PluginLoader treats None as "no local commands", so a bot without a local
directory loads only the built-in plugins.
"""
local_dir_path = config.get('Bot', 'local_dir_path', fallback='local')
local_commands_dir = resolve_path(os.path.join(local_dir_path, 'commands'), bot_root)
if not os.path.isdir(local_commands_dir):
logger.info(f"Local commands directory not found: {local_commands_dir}")
return None
logger.info(f"Using local commands directory: {local_commands_dir}")
return local_commands_dir
def is_command_enabled(cmd_instance: Any, config: configparser.ConfigParser) -> bool:
"""Report whether a command is enabled in the config.
Defers to the same section derivation and legacy `enabled` aliases the bot
uses at runtime, so a command the bot will not answer is not advertised on
the generated page. Commands with no name and commands with no explicit
setting are treated as enabled.
Args:
cmd_instance: Command instance to check
config: Config parser instance
Returns:
bool: True if command is enabled, False otherwise
"""
cmd_name = getattr(cmd_instance, 'name', None)
if not cmd_name:
return True
return read_enabled(config, command_section_name(cmd_name), default=True)
def filter_commands(commands: dict[str, Any], admin_commands: list[str], config: configparser.ConfigParser) -> dict[str, Any]:
"""Filter out admin, hidden, and disabled commands"""
filtered = {}
# Categories to exclude from public reference
@@ -1156,6 +1213,10 @@ def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict
if hasattr(cmd_instance, 'category') and cmd_instance.category in excluded_categories:
continue
# Skip commands that mark themselves hidden
if getattr(cmd_instance, 'hidden', False):
continue
# Get primary command name
primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
@@ -1171,6 +1232,10 @@ def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict
if hasattr(cmd_instance, 'keywords') and not cmd_instance.keywords:
continue
# Skip commands that are disabled in config
if not is_command_enabled(cmd_instance, config):
continue
filtered[cmd_name] = cmd_instance
return filtered
@@ -1284,7 +1349,7 @@ def format_monitor_channels(monitor_channels: list[str], html: bool = False) ->
return ", ".join(formatted[:-1]) + f", or {formatted[-1]}"
def generate_html(bot_name: str, title: str, introduction: str, commands: list[tuple[str, Any]], monitor_channels: list[str] = None, channels_data: dict[str, dict[str, str]] = None, style: str = 'default') -> str:
def generate_html(bot_name: str, title: str, introduction: str, commands: list[tuple[str, Any]], monitor_channels: list[str] = None, channels_data: dict[str, dict[str, str]] = None, style: str = 'default', link_css: Optional[str] = None, custom_css: Optional[str] = None) -> str:
"""Generate the HTML content"""
if monitor_channels is None:
@@ -1519,6 +1584,17 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
channels_html += '</div>\n'
# Custom CSS cascades after the built-in style: embedded CSS is appended to
# the <style> block and a linked stylesheet follows it, so both can override
# built-in rules of equal specificity.
css_content = generate_builtin_css(style)
if custom_css:
css_content += "\n\n /* Custom CSS overrides */\n" + custom_css
external_css_link = ""
if link_css:
external_css_link = f'\n <link rel="stylesheet" href="{escape_html(link_css)}">'
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -1529,7 +1605,144 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="{STYLES[style]['fonts_url']}" rel="stylesheet">
<style>
:root {{
{css_content}
</style>{external_css_link}
</head>"""
# Continue with body
html_content += f"""
<body>
<div class="atmosphere"></div>
<div class="grid-overlay"></div>
<div class="sidebar-overlay"></div>
<button class="mobile-menu-toggle" aria-label="Toggle navigation menu">
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
{nav_html}
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>{escape_html(bot_name)}</h1>
</div>
<div class="intro">
{escape_html(introduction).replace(chr(10), '<br>')}{channels_suffix}
</div>
</div>
</header>
<main>
{commands_html}
{channels_html}
</main>
<footer>
<p>Generated command reference for {escape_html(bot_name)}</p>
</footer>
</div>
</div>
<script>
// Handle mobile menu toggle
(function() {{
'use strict';
const menuToggle = document.querySelector('.mobile-menu-toggle');
const sidebar = document.querySelector('.sidebar-nav');
const overlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {{
function toggleMenu() {{
const isOpen = sidebar.classList.contains('open');
if (isOpen) {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}} else {{
menuToggle.classList.add('active');
sidebar.classList.add('open');
if (overlay) {{
overlay.classList.add('visible');
}}
}}
}}
function closeMenu() {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}}
menuToggle.addEventListener('click', function(e) {{
e.stopPropagation();
e.preventDefault();
toggleMenu();
return false;
}});
// Handle overlay clicks - overlay now only covers area after sidebar
if (overlay) {{
overlay.addEventListener('click', function(e) {{
closeMenu();
}});
}}
// Handle nav link clicks - don't prevent default, let browser handle navigation
const navLinks = sidebar.querySelectorAll('.nav-link');
navLinks.forEach(link => {{
link.addEventListener('click', function(e) {{
if (window.innerWidth <= 1200) {{
// Close menu after a delay to allow navigation
setTimeout(function() {{
closeMenu();
}}, 200);
}}
}});
}});
}}
// Handle keyword expansion
const expandButtons = document.querySelectorAll('.keyword-expand');
expandButtons.forEach(button => {{
button.addEventListener('click', function() {{
const hiddenAliases = this.getAttribute('data-hidden').split(',');
const commandKeywords = this.closest('.command-keywords');
// Hide the expand button
this.classList.add('expanded');
// Add hidden aliases as visible badges
hiddenAliases.forEach(alias => {{
const badge = document.createElement('span');
badge.className = 'keyword-badge keyword-hidden visible';
badge.textContent = alias.trim();
commandKeywords.appendChild(badge);
}});
}});
}});
}})();
</script>
</body>
</html>"""
return html_content
def generate_builtin_css(style: str) -> str:
"""Generate the built-in CSS for a given style"""
return f""" :root {{
{STYLES[style]['css_vars']}
}}
@@ -2368,136 +2581,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
}}
/* Style-specific CSS overrides */
{STYLES[style].get('css_overrides', '')}
</style>
</head>
<body>
<div class="atmosphere"></div>
<div class="grid-overlay"></div>
<div class="sidebar-overlay"></div>
<button class="mobile-menu-toggle" aria-label="Toggle navigation menu">
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
{nav_html}
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>{escape_html(bot_name)}</h1>
</div>
<div class="intro">
{escape_html(introduction).replace(chr(10), '<br>')}{channels_suffix}
</div>
</div>
</header>
<main>
{commands_html}
{channels_html}
</main>
<footer>
<p>Generated command reference for {escape_html(bot_name)}</p>
</footer>
</div>
</div>
<script>
// Handle mobile menu toggle
(function() {{
'use strict';
const menuToggle = document.querySelector('.mobile-menu-toggle');
const sidebar = document.querySelector('.sidebar-nav');
const overlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {{
function toggleMenu() {{
const isOpen = sidebar.classList.contains('open');
if (isOpen) {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}} else {{
menuToggle.classList.add('active');
sidebar.classList.add('open');
if (overlay) {{
overlay.classList.add('visible');
}}
}}
}}
function closeMenu() {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}}
menuToggle.addEventListener('click', function(e) {{
e.stopPropagation();
e.preventDefault();
toggleMenu();
return false;
}});
// Handle overlay clicks - overlay now only covers area after sidebar
if (overlay) {{
overlay.addEventListener('click', function(e) {{
closeMenu();
}});
}}
// Handle nav link clicks - don't prevent default, let browser handle navigation
const navLinks = sidebar.querySelectorAll('.nav-link');
navLinks.forEach(link => {{
link.addEventListener('click', function(e) {{
if (window.innerWidth <= 1200) {{
// Close menu after a delay to allow navigation
setTimeout(function() {{
closeMenu();
}}, 200);
}}
}});
}});
}}
// Handle keyword expansion
const expandButtons = document.querySelectorAll('.keyword-expand');
expandButtons.forEach(button => {{
button.addEventListener('click', function() {{
const hiddenAliases = this.getAttribute('data-hidden').split(',');
const commandKeywords = this.closest('.command-keywords');
// Hide the expand button
this.classList.add('expanded');
// Add hidden aliases as visible badges
hiddenAliases.forEach(alias => {{
const badge = document.createElement('span');
badge.className = 'keyword-badge keyword-hidden visible';
badge.textContent = alias.trim();
commandKeywords.appendChild(badge);
}});
}});
}});
}})();
</script>
</body>
</html>"""
return html_content
{STYLES[style].get('css_overrides', '')}"""
def list_styles():
@@ -2513,7 +2597,7 @@ def list_styles():
print(f" {' ' * max_len} {style_info['description']}\n")
def generate_samples(config_file):
def generate_samples(config_file, link_css: Optional[str] = None, custom_css: Optional[str] = None):
"""Generate sample HTML files for all styles with an index page."""
logger = logging.getLogger(__name__)
@@ -2539,7 +2623,7 @@ def generate_samples(config_file):
channels_data = load_channels_from_config(config)
# Setup minimal bot for plugin loading
minimal_bot = MinimalBot(config, logger)
minimal_bot = MinimalBot(config, logger, bot_root=bot_root)
# Initialize database manager if database exists
db_path = get_database_path(config, bot_root)
@@ -2552,15 +2636,14 @@ def generate_samples(config_file):
else:
minimal_bot.db_manager = None
local_commands_dir = resolve_local_commands_dir(config, bot_root, logger)
# Load plugins
plugin_loader = PluginLoader(minimal_bot)
plugin_loader = PluginLoader(minimal_bot, local_commands_dir=local_commands_dir)
commands = plugin_loader.load_all_plugins()
# Filter out admin and hidden commands
public_commands = {
name: cmd for name, cmd in commands.items()
if name not in admin_commands and not getattr(cmd, 'hidden', False)
}
# Filter out admin, hidden, and disabled commands
public_commands = filter_commands(commands, admin_commands, config)
public_commands.update(get_randomline_commands(config))
# Sort commands
@@ -2582,7 +2665,9 @@ def generate_samples(config_file):
commands=sorted_commands,
monitor_channels=monitor_channels,
channels_data=channels_data,
style=style_name
style=style_name,
link_css=link_css,
custom_css=custom_css
)
output_path = os.path.join(output_dir, f'{style_name}.html')
@@ -2730,6 +2815,18 @@ def main():
help='Generate sample pages for all styles with an index.html'
)
parser.add_argument(
'--link-css',
metavar='URL',
help='Link a stylesheet after the built-in style so its rules override it'
)
parser.add_argument(
'--embed-css',
metavar='FILE',
help='Inline CSS from a local file after the built-in style so its rules override it'
)
args = parser.parse_args()
# Handle --list-styles flag
@@ -2737,9 +2834,19 @@ def main():
list_styles()
sys.exit(0)
# Read --embed-css up front so a bad path fails before plugins load
custom_css = None
if args.embed_css:
try:
with open(args.embed_css, encoding='utf-8') as f:
custom_css = f.read()
except (OSError, UnicodeDecodeError) as e:
logger.error(f"Could not read --embed-css file {args.embed_css}: {e}")
sys.exit(1)
# Handle --sample flag
if args.sample:
generate_samples(args.config)
generate_samples(args.config, link_css=args.link_css, custom_css=custom_css)
sys.exit(0)
config_file = args.config
@@ -2774,7 +2881,7 @@ def main():
logger.info(f"Monitor channels: {monitor_channels}")
# Setup minimal bot for plugin loading
minimal_bot = MinimalBot(config, logger)
minimal_bot = MinimalBot(config, logger, bot_root=bot_root)
# Initialize database manager if database exists
db_path = get_database_path(config, bot_root)
@@ -2789,14 +2896,16 @@ def main():
logger.info("No database found, using default command ordering")
minimal_bot.db_manager = None
local_commands_dir = resolve_local_commands_dir(config, bot_root, logger)
# Load plugins
logger.info("Loading command plugins...")
plugin_loader = PluginLoader(minimal_bot)
plugin_loader = PluginLoader(minimal_bot, local_commands_dir=local_commands_dir)
commands = plugin_loader.load_all_plugins()
logger.info(f"Loaded {len(commands)} commands")
# Filter out admin and hidden commands
filtered_commands = filter_commands(commands, admin_commands)
# Filter out admin, hidden, and disabled commands
filtered_commands = filter_commands(commands, admin_commands, config)
filtered_commands.update(get_randomline_commands(config))
logger.info(f"Filtered to {len(filtered_commands)} public commands")
@@ -2822,7 +2931,7 @@ def main():
# Generate HTML
logger.info("Generating HTML...")
html_content = generate_html(bot_name, title, introduction, sorted_commands, monitor_channels, channels_data, style)
html_content = generate_html(bot_name, title, introduction, sorted_commands, monitor_channels, channels_data, style, args.link_css, custom_css)
# Create website directory
website_dir = os.path.join(bot_root, "website")
+1
View File
@@ -67,6 +67,7 @@ nav:
- Overview: command-reference.md
- Repeater Commands: repeater-commands.md
- Custom Command Website: command-reference-website.md
- Developing Commands: develop-command-scripts.md
- Service Plugins:
- Overview: service-plugins.md
- Discord Bridge: discord-bridge.md
+39 -15
View File
@@ -49,15 +49,26 @@ class ChannelManager:
"""Lowercase channel name without a leading # for cache lookups."""
return name.removeprefix("#").lower()
async def fetch_channels(self):
"""Fetch channels from the MeshCore node using enhanced concurrent fetching"""
async def fetch_channels(self, max_attempts: int = 3, retry_delay: float = 2.0) -> bool:
"""Fetch configured channels, retrying when the node returns no usable data."""
self.logger.info("Fetching channels from MeshCore node using enhanced concurrent method...")
try:
# Wait a moment for the device to be ready
await asyncio.sleep(2)
if max_attempts < 1:
raise ValueError("max_attempts must be at least 1")
# Fetch all channels concurrently
channels = await self.fetch_all_channels(force_refresh=True)
# Give the companion protocol a moment to settle after transport connect.
await asyncio.sleep(2)
for attempt in range(1, max_attempts + 1):
try:
channels = await self.fetch_all_channels(force_refresh=True)
except Exception as e:
channels = []
self.logger.warning(
"Channel fetch attempt %d/%d failed: %s",
attempt,
max_attempts,
e,
)
if channels:
self.logger.info(f"Successfully fetched {len(channels)} channels from MeshCore node")
@@ -68,13 +79,25 @@ class ChannelManager:
self.logger.info(f" Channel {channel_idx}: {channel_name}")
else:
self.logger.debug(f" Channel {channel_idx}: (empty)")
else:
self.logger.warning("No channels found on MeshCore node")
self.bot.meshcore.channels = {}
return True
except Exception as e:
self.logger.error(f"Failed to fetch channels: {e}")
self._cache_valid = False
self.bot.meshcore.channels = {}
if attempt < max_attempts:
self.logger.warning(
"No channels received from MeshCore node on attempt %d/%d; "
"retrying in %.1fs",
attempt,
max_attempts,
retry_delay,
)
await asyncio.sleep(retry_delay)
self.logger.error(
"No channels received from MeshCore node after %d attempt(s)",
max_attempts,
)
return False
async def fetch_all_channels(self, force_refresh: bool = False) -> list[dict[str, Any]]:
"""
@@ -136,14 +159,15 @@ class ChannelManager:
break
continue
self._cache_valid = True
self._cache_valid = bool(valid_channels)
self.logger.info(f"Successfully fetched {len(valid_channels)} channels")
# Update the bot's meshcore channels for compatibility
self.bot.meshcore.channels = self._channels_cache
# Store channels in database for web viewer access
self._store_channels_in_db(valid_channels)
# Do not erase the last known channel table on a transient all-empty scan.
if valid_channels:
self._store_channels_in_db(valid_channels)
return valid_channels
+35 -29
View File
@@ -5,6 +5,7 @@ Handles all bot commands, keyword matching, and response generation
"""
import asyncio
import contextlib
import random
import time
from dataclasses import dataclass
@@ -1347,37 +1348,50 @@ class CommandManager:
scope_to_use,
scope_source,
)
if not scope_is_global and not hasattr(self.bot.meshcore.commands, "set_flood_scope"):
scoped = not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope")
if not scope_is_global and not scoped:
self.logger.warning(
"Regional flood scope %r requested but meshcore.commands.set_flood_scope "
"is unavailable; channel message will use device default (often global flood)",
scope_to_use,
)
elif not scope_is_global:
_scope_result = await self.bot.meshcore.commands.set_flood_scope(scope_to_use)
if _scope_result is None or getattr(_scope_result, "type", None) == "ERROR":
self.logger.warning(
"set_flood_scope(%s) failed (result=%s); "
"message will be sent with current firmware scope",
scope_to_use, _scope_result,
)
target = f"{channel} (channel {channel_num})"
# Retry on no_event_received: max 2 extra attempts, 2s apart
_max_retries = 2
for _attempt in range(_max_retries + 1):
try:
result = await self.bot.meshcore.commands.send_chan_msg(
channel_num, content,
timestamp=int(timestamp.timestamp()) if timestamp else None,
)
finally:
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
_restore_result = await self.bot.meshcore.commands.set_flood_scope("*")
if _restore_result is None or getattr(_restore_result, "type", None) == "ERROR":
self.logger.warning(
"set_flood_scope('*') restore failed (result=%s)", _restore_result
)
# Hold the radio from set to restore so no other send goes out
# under this message's scope.
async with self.bot.radio_session() if scoped else contextlib.nullcontext():
# The set is inside the try as well: a raising set_flood_scope
# would otherwise leave the device pinned to this region, and
# every later send would go out under it.
try:
if scoped:
_scope_result = await self.bot.meshcore.commands.set_flood_scope(scope_to_use)
if _scope_result is None or getattr(_scope_result, "type", None) == "ERROR":
if _attempt == 0:
self.logger.warning(
"set_flood_scope(%s) failed (result=%s); "
"message will be sent with current firmware scope",
scope_to_use, _scope_result,
)
else:
self.logger.warning(
"set_flood_scope(%s) failed on retry re-apply (result=%s)",
scope_to_use, _scope_result,
)
result = await self.bot.meshcore.commands.send_chan_msg(
channel_num, content,
timestamp=int(timestamp.timestamp()) if timestamp else None,
)
finally:
if scoped:
_restore_result = await self.bot.meshcore.commands.set_flood_scope("*")
if _restore_result is None or getattr(_restore_result, "type", None) == "ERROR":
self.logger.warning(
"set_flood_scope('*') restore failed (result=%s)", _restore_result
)
if self._is_no_event_received(result) and _attempt < _max_retries:
self.logger.warning(
@@ -1385,14 +1399,6 @@ class CommandManager:
f"(attempt {_attempt + 1}/{_max_retries + 1}), retrying in 2s"
)
await asyncio.sleep(2)
# Re-apply scope for next attempt
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
_scope_result = await self.bot.meshcore.commands.set_flood_scope(scope_to_use)
if _scope_result is None or getattr(_scope_result, "type", None) == "ERROR":
self.logger.warning(
"set_flood_scope(%s) failed on retry re-apply (result=%s)",
scope_to_use, _scope_result,
)
continue
break
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Contact command for the MeshCore Bot
Adds the bot contact info to the current channel
"""
import re
from typing import Any, Optional
from modules.commands.base_command import BaseCommand
from modules.models import MeshMessage
PUBLIC_KEY_RE = re.compile(r'^[0-9a-fA-F]{64}$')
class ContactCommand(BaseCommand):
"""Handles contact command"""
# Plugin metadata
name = "contact"
keywords = ['contact']
description = "Display the bot's contact information"
category = "basic"
# Documentation
short_description = "Display the bot's contact information"
usage = "contact"
examples = [
"contact"
]
def __init__(self, bot):
"""Initialize the contact command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self.enabled = self.get_config_value('Contact_Command', 'enabled', fallback=True, value_type='bool')
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Check if this command can be executed with the given message.
Args:
message: The message triggering the command.
skip_channel_check: If True, skip the channel check.
Returns:
bool: True if command is enabled and checks pass, False otherwise.
"""
if not self.enabled:
return False
return super().can_execute(message, skip_channel_check=skip_channel_check)
def get_help_text(self) -> str:
"""Get help text for the contact command.
Returns:
str: Help text string.
"""
return self.translate('commands.contact.help')
def matches_keyword(self, message: MeshMessage) -> bool:
"""Match ``contact`` (or a configured alias) on its own, with no arguments.
Args:
message: The received message.
Returns:
bool: True if the message is a contact command, False otherwise.
"""
def _matches(content_lower: str) -> bool:
return any(content_lower == keyword.lower() for keyword in self.keywords)
return self._cleaned_content_matches(message, _matches)
def _self_info_value(self, key: str) -> Optional[str]:
"""Read a field from the radio's self_info, which may be a dict or an object.
Args:
key: The self_info field name.
Returns:
str: The field value, or None if unavailable.
"""
meshcore: Any = getattr(self.bot, 'meshcore', None)
self_info = getattr(meshcore, 'self_info', None) if meshcore else None
if not self_info:
return None
if isinstance(self_info, dict):
value = self_info.get(key)
else:
value = getattr(self_info, key, None)
return str(value).strip() if value else None
async def execute(self, message: MeshMessage) -> bool:
"""Execute the contact command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
public_key = self._self_info_value('public_key')
name = self._self_info_value('name') or self._self_info_value('adv_name')
if not public_key or not PUBLIC_KEY_RE.match(public_key):
self.logger.warning("Contact command: no usable public key in self_info")
return await self.send_response(message, self.translate('commands.contact.unavailable'))
if not name:
self.logger.warning("Contact command: no device name in self_info")
return await self.send_response(message, self.translate('commands.contact.unavailable'))
return await self.send_response(message, f"<{public_key.lower()}:1:{name}>")
+103 -15
View File
@@ -10,6 +10,7 @@ import re
from typing import Any
from ..models import MeshMessage
from ..security_utils import sanitize_name
from ..utils import get_config_timezone
from .base_command import BaseCommand
@@ -23,6 +24,23 @@ class HelloCommand(BaseCommand):
description = "Responds to greetings with robot-themed responses"
category = "basic"
# How the sender is addressed when include_sender is on. The greeting already
# has a slot for the person being greeted — the random human descriptor
# ("pal", "carbon-based lifeform") -- so the mention is substituted there
# rather than prepended, which keeps the translated sentence intact.
SENDER_MENTION_FORMAT = "@[{sender}]"
# message_handler falls back to this literal when a channel packet carries no
# "Name: body" prefix. It names nobody, so it must never be echoed as a mention.
UNKNOWN_CHANNEL_SENDER = "Channel User"
# Web-viewer settings schema (see modules/settings_schema.py)
settings_schema = [
{"key": "include_sender", "label": "Address the sender", "type": "bool",
"default": False,
"help": "Name the user in the reply, so a busy channel can tell whose greeting was answered."},
]
# Documentation
short_description = "Responds to greetings with robot-themed responses"
usage = "hello"
@@ -38,10 +56,38 @@ class HelloCommand(BaseCommand):
# Load configuration
self.hello_enabled = self.get_config_value('Hello_Command', 'enabled', fallback=True, value_type='bool')
self.include_sender = self.get_config_value('Hello_Command', 'include_sender', fallback=False, value_type='bool')
# Fallback arrays if translations not available
self._init_fallback_arrays()
def get_sender_mention(self, message: MeshMessage) -> str:
"""Build the @[name] mention for the sender of *message*.
Args:
message: The message being answered.
Returns:
str: The mention, or "" when the sender must not be named — the
setting is off, the reply is a DM (the recipient is the only
other party), or the name is missing/unresolvable.
"""
if not self.include_sender or message.is_dm:
return ""
# Channel sender names are parsed straight off the wire in
# message_handler.handle_channel_message and are not sanitized there, so
# treat them as hostile. Control characters go; so do brackets, which
# would let a crafted name close the mention and forge a second one, and
# braces, which would raise from the .format(bot_name=...) the assembled
# reply still has to go through.
sender = sanitize_name(message.sender_id or "")
sender = sender.translate(str.maketrans("", "", "[]{}")).strip()
if not sender or sender == self.UNKNOWN_CHANNEL_SENDER:
return ""
return self.SENDER_MENTION_FORMAT.format(sender=sender)
def _init_fallback_arrays(self) -> None:
"""Initialize fallback arrays for when translations are not available."""
# Time-neutral greeting openings
@@ -297,24 +343,54 @@ class HelloCommand(BaseCommand):
# Strip mentions from content for processing
content = self._strip_mentions(message.content)
mention = self.get_sender_mention(message)
# Build the reply in the sender's language when auto-detection is enabled.
# The full response string is assembled inside the context manager; the
# await (send_response) happens after it, keeping the override window
# free of suspension points.
with self.respond_in_sender_language(message):
# Check if message is emoji-only (after stripping mentions)
if self.is_emoji_only_message(content):
response = self.get_emoji_response(content, bot_name)
else:
# Get random robot greeting
random_greeting = self.get_random_greeting()
response_format = self.translate('commands.hello.response_format')
response = f"{random_greeting} {response_format}".format(bot_name=bot_name)
response = self._build_response(content, bot_name, mention)
# Nothing downstream truncates, so an over-budget reply is cut by the
# firmware mid-word. A long node name can push the reply past the
# channel body budget; drop the mention rather than lose the tail.
if mention and len(response.encode('utf-8')) > self.get_max_message_length(message):
response = self._build_response(content, bot_name, "")
return await self.send_response(message, response)
def get_random_greeting(self) -> str:
"""Generate a random robot greeting by combining opening and descriptor"""
def _build_response(self, content: str, bot_name: str, mention: str) -> str:
"""Assemble the reply for *content*, addressing *mention* when set.
Args:
content: The incoming message body, with mentions already stripped.
bot_name: Name to substitute into the response format.
mention: The sender's @[name] mention, or "" to leave them unnamed.
Returns:
str: The assembled reply.
"""
# Check if message is emoji-only (after stripping mentions)
if self.is_emoji_only_message(content):
return self.get_emoji_response(content, bot_name, mention)
# Get random robot greeting
random_greeting = self.get_random_greeting(mention)
response_format = self.translate('commands.hello.response_format')
return f"{random_greeting} {response_format}".format(bot_name=bot_name)
def get_random_greeting(self, mention: str = "") -> str:
"""Generate a random robot greeting by combining opening and descriptor.
Args:
mention: When set, the sender's @[name] mention is used in place of
the random human descriptor, so the reply addresses the
person by name inside the sentence the translation defines.
Returns:
str: The assembled greeting.
"""
tz, _ = get_config_timezone(self.bot.config, self.logger)
current_time = datetime.datetime.now(tz)
@@ -339,7 +415,7 @@ class HelloCommand(BaseCommand):
greeting_pool = evening_greetings + greeting_openings
opening = random.choice(greeting_pool)
descriptor = random.choice(human_descriptors)
descriptor = mention or random.choice(human_descriptors)
# Add some variety in punctuation and formatting
punctuation_options = ["!", ".", "!", "!", "!"] # Favor exclamation marks
@@ -381,8 +457,19 @@ class HelloCommand(BaseCommand):
# Call parent can_execute() which includes channel checking, cooldown, etc.
return super().can_execute(message)
def get_emoji_response(self, text: str, bot_name: str) -> str:
"""Get appropriate response for emoji-only message"""
def get_emoji_response(self, text: str, bot_name: str, mention: str = "") -> str:
"""Get appropriate response for emoji-only message.
Args:
text: The emoji-only message body.
bot_name: Name to substitute into the response format.
mention: When set, the sender's @[name] mention. The canned emoji
lines have no descriptor slot to substitute it into, so it
is prefixed instead; they are short enough to afford it.
Returns:
str: The assembled response.
"""
import random
# Get emoji responses from translations or fallback
@@ -395,8 +482,9 @@ class HelloCommand(BaseCommand):
# Check if this emoji has special responses
if first_emoji in emoji_responses:
response = random.choice(emoji_responses[first_emoji])
return f"{response} {response_format}".format(bot_name=bot_name)
prefix = f"{mention} " if mention else ""
return f"{prefix}{response} {response_format}".format(bot_name=bot_name)
else:
# Use random greeting generator for general emojis
random_greeting = self.get_random_greeting()
random_greeting = self.get_random_greeting(mention)
return f"{random_greeting} {response_format}".format(bot_name=bot_name)
+2 -2
View File
@@ -50,8 +50,8 @@ LEGACY_ENABLED_KEY_RE = re.compile(r"^[a-z]+_enabled$")
# behavior and the UI can never disagree.
# Maps canonical section -> ordered ((legacy_section, legacy_key), ...).
LEGACY_ENABLED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = {
"Joke_Command": (("Jokes", "joke_enabled"),),
"DadJoke_Command": (("Jokes", "dadjoke_enabled"),),
"Joke_Command": (("Joke_Command", "joke_enabled"), ("Jokes", "joke_enabled")),
"DadJoke_Command": (("DadJoke_Command", "dadjoke_enabled"), ("Jokes", "dadjoke_enabled")),
"Stats_Command": (("Stats_Command", "stats_enabled"), ("Stats", "stats_enabled")),
"Sports_Command": (("Sports_Command", "sports_enabled"), ("Sports", "sports_enabled")),
"Hacker_Command": (("Hacker_Command", "hacker_enabled"), ("Hacker", "hacker_enabled")),
+73 -45
View File
@@ -7,8 +7,9 @@ Contains the main bot class and message processing logic
import asyncio
import atexit
import configparser
import contextlib
import contextvars
import functools
import inspect
import json
import logging
import signal
@@ -119,8 +120,15 @@ class _BotAdminServer(threading.Thread):
self._bot.logger.error("BotAdminServer failed to start: %s", exc)
class _SerializedCommands:
"""Serializing proxy around ``meshcore.commands``.
# True while the current task holds the radio through MeshCoreBot.radio_session(),
# so the frames it sends don't try to take the (non-reentrant) lock again.
_radio_session_held: contextvars.ContextVar[bool] = contextvars.ContextVar(
"radio_session_held", default=False
)
def _serialize_command_frames(bot: "MeshCoreBot", commands: Any) -> bool:
"""Route every host->radio frame through the bot's radio command lock.
The companion firmware processes one host serial frame per main-loop
iteration and has no mid-frame resync: a burst of concurrent commands can
@@ -130,37 +138,33 @@ class _SerializedCommands:
(sends, channel/contact ops, scheduler ops, health probes, auto message
fetch) with no shared serialization.
This proxy routes every coroutine command through a single per-bot lock and
enforces a minimum inter-command interval, guaranteeing at most one
in-flight companion frame at a time. Non-coroutine attributes are passed
through untouched, so library internals that read ``_sender_func``,
``default_timeout`` etc. are unaffected. ``meshcore_cli.next_cmd`` calls are
serialized too, since they dispatch through this same ``commands`` object.
Every meshcore command writes its frame through ``CommandHandler.send()``,
which waits for the radio's immediate reply (OK, ERROR, MSG_SENT, ...).
Wrapping ``send`` on the handler instance serializes exactly that exchange
and paces frames by a minimum interval, so there is at most one in-flight
companion frame at a time. Library methods call ``self.send``, so composite
commands (``send_msg_with_retry``, ``req_*_sync``, ``send_login_sync``) and
``meshcore_cli.next_cmd`` are covered too, while their waits for ACKs and
remote responses happen outside the lock and don't block other senders.
Returns False when ``commands`` is already serialized.
"""
send = commands.send
if getattr(send, "_radio_serialized", False):
return False
__slots__ = ("_bot", "_commands")
@functools.wraps(send)
async def _serialized_send(*args: Any, **kwargs: Any) -> Any:
if _radio_session_held.get():
await bot._pace_radio_command()
return await send(*args, **kwargs)
async with bot._get_radio_cmd_lock():
await bot._pace_radio_command()
return await send(*args, **kwargs)
def __init__(self, bot: "MeshCoreBot", commands: Any) -> None:
object.__setattr__(self, "_bot", bot)
object.__setattr__(self, "_commands", commands)
def __getattr__(self, name: str) -> Any:
commands = object.__getattribute__(self, "_commands")
attr = getattr(commands, name)
if not inspect.iscoroutinefunction(attr):
return attr
bot = object.__getattribute__(self, "_bot")
@functools.wraps(attr)
async def _serialized(*args: Any, **kwargs: Any) -> Any:
async with bot._get_radio_cmd_lock():
await bot._pace_radio_command()
return await attr(*args, **kwargs)
return _serialized
def __setattr__(self, name: str, value: Any) -> None:
setattr(object.__getattribute__(self, "_commands"), name, value)
_serialized_send._radio_serialized = True # type: ignore[attr-defined]
commands.send = _serialized_send
return True
class MeshCoreBot:
@@ -1752,25 +1756,46 @@ long_jokes = false
await asyncio.sleep(wait)
self._radio_cmd_last_ts = time.monotonic()
def _install_command_serializer(self) -> None:
"""Wrap ``meshcore.commands`` so every command is serialized + paced.
@contextlib.asynccontextmanager
async def radio_session(self):
"""Hold the radio for a short sequence of frames that must not interleave.
Idempotent and safe to call after each (re)connect. Wrapping the
``commands`` attribute in place means existing call sites
(``self.meshcore.commands.*`` and ``meshcore_cli.next_cmd``) are
serialized automatically with no per-call changes.
Frames are serialized one at a time, so another task's frame can land
between two of ours. Use this when that matters, e.g. setting the flood
scope, sending, and restoring it, so no other send goes out under the
temporary scope. Keep it short: every other sender waits, so don't wait
for ACKs or remote responses inside it. Re-entering from the same task
is a no-op. Tasks created inside the session inherit it, so don't spawn
work that sends after the session ends.
"""
if _radio_session_held.get():
yield
return
async with self._get_radio_cmd_lock():
token = _radio_session_held.set(True)
try:
yield
finally:
_radio_session_held.reset(token)
def _install_command_serializer(self) -> None:
"""Serialize and pace every frame ``meshcore.commands`` writes.
Idempotent and safe to call after each (re)connect. The handler is
wrapped in place, so existing call sites (``self.meshcore.commands.*``
and ``meshcore_cli.next_cmd``) need no per-call changes.
"""
if not self.meshcore:
return
cmds = getattr(self.meshcore, "commands", None)
if cmds is None or isinstance(cmds, _SerializedCommands):
if cmds is None:
return
try:
self.meshcore.commands = _SerializedCommands(self, cmds)
self.logger.debug(
"Installed serialized command gateway (min interval %.0fms)",
self._radio_cmd_min_interval * 1000,
)
if _serialize_command_frames(self, cmds):
self.logger.debug(
"Installed serialized command gateway (min interval %.0fms)",
self._radio_cmd_min_interval * 1000,
)
except (AttributeError, TypeError) as e:
self.logger.warning(f"Could not install command serializer: {e}")
@@ -1852,8 +1877,11 @@ long_jokes = false
# Wait for contacts to load
await self.wait_for_contacts()
# Fetch channels
await self.channel_manager.fetch_channels()
# A connected transport without channel data cannot route replies.
if not await self.channel_manager.fetch_channels():
raise ConnectionError(
"MeshCore node returned no channels after retries"
)
# Setup message event handlers
await self.setup_message_handlers()
+6 -1
View File
@@ -9,6 +9,7 @@ import copy
import hmac as hmac_mod
import time
from collections import OrderedDict
from collections.abc import Iterable
from hashlib import sha256
from typing import Any, TypedDict
@@ -485,6 +486,7 @@ class MessageHandler:
"""Return configured channel indexes and keys without logging secrets."""
meshcore = getattr(self.bot, "meshcore", None)
channels = getattr(meshcore, "channels", None)
items: Iterable[tuple[int, Any]]
if isinstance(channels, dict):
items = channels.items()
elif isinstance(channels, list):
@@ -496,8 +498,11 @@ class MessageHandler:
for fallback_idx, channel in items:
if not isinstance(channel, dict):
continue
# Annotated Any: with a mixed Any | int default, mypy matches .get()'s
# None-default overload and infers Any | None. int() rejects None anyway.
raw_idx: Any = channel.get("channel_idx", fallback_idx)
try:
channel_idx = int(channel.get("channel_idx", fallback_idx))
channel_idx = int(raw_idx)
except (TypeError, ValueError):
continue
+19 -21
View File
@@ -18,25 +18,23 @@ link* between two full 32-byte public keys, with a measured SNR. That is
stronger evidence than anything path inference can offer, and it costs one radio
command plus a passive listen window.
Stage 2 (scopes) is deliberately optional here and defaults off, for two reasons
that do not apply to the upstream capture tool:
Stage 2 (scopes) is deliberately optional here and defaults off, for a reason
that does not apply to the upstream capture tool: upstream relies on a freshly
discovered neighbor *not* being a known contact, which is what makes
``send_anon_req`` ask for a zero-hop reply path. This bot populates the
library's contact cache. For a contact with no path (``out_path_len == -1``,
the common case for a flood repeater) the library reaches zero-hop by calling
``change_contact_path()`` and then ``reset_path()`` -- i.e. it *mutates the
device's contact table* per neighbor. Those two calls are not paired by
``try``/``finally`` upstream, so a request cut short between them leaves the
contact pinned to zero-hop; collect_scopes restores it itself (see
``_restore_flood_path``).
* ``req_regions_sync`` awaits its reply inside the call, and every bot command
is serialized through ``modules.core._SerializedCommands``, so one scope
request holds the radio for its whole round trip -- stalling message sends.
* Upstream relies on a freshly discovered neighbor *not* being a known contact,
which is what makes ``send_anon_req`` ask for a zero-hop reply path. This bot
populates the library's contact cache. For a contact with no path
(``out_path_len == -1``, the common case for a flood repeater) the library
reaches zero-hop by calling ``change_contact_path()`` and then
``reset_path()`` -- i.e. it *mutates the device's contact table* per neighbor.
Those two calls are not paired by ``try``/``finally`` upstream, so a request
cut short between them leaves the contact pinned to zero-hop; collect_scopes
restores it itself (see ``_restore_flood_path``).
No device-command lock is passed around: unlike upstream, every coroutine on
``meshcore.commands`` is already serialized and paced by
``modules.core._SerializedCommands``.
No device-command lock is passed around: unlike upstream, every frame
``meshcore.commands`` writes is already serialized and paced by
``modules.core._serialize_command_frames``. The lock covers each frame and its
immediate reply only, so waiting for a neighbor's scope reply doesn't stall the
bot's other sends.
"""
from __future__ import annotations
@@ -484,9 +482,9 @@ async def collect_scopes(
pinned_to_zero_hop = _contact_has_no_path(meshcore, entry.pubkey)
try:
# Bounded: this holds the shared radio command lock for its whole
# round trip, and an unbounded stall here would block every other
# bot command for as long as the write hangs.
# Bounded: a hung write holds the shared radio command lock, and an
# unbounded stall here would block every other bot command for as
# long as the write hangs.
scopes = await asyncio.wait_for(
meshcore.commands.req_regions_sync(
entry.pubkey,
+2 -2
View File
@@ -33,8 +33,8 @@ dependencies = [
"maidenhead>=1.4.0",
"pytz>=2023.3",
"aiohttp>=3.8.0",
# 2.3.8+ required for zero-hop neighbour discovery (see requirements.txt)
"meshcore>=2.3.8",
# 2.3.14+ required for send_msg_with_retry's ACK handling (see requirements.txt)
"meshcore>=2.3.14",
"openmeteo-requests>=1.7.2",
"requests-cache>=1.1.1",
"retry-requests>=1.0.0",
+8 -3
View File
@@ -13,9 +13,14 @@ geopy>=2.3.0
maidenhead>=1.4.0
pytz>=2023.3
aiohttp>=3.8.0
# 2.3.8+ is required, not merely preferred: send_node_discover_req / req_regions_sync
# (zero-hop neighbour discovery) and the bounded, serialised BLE write both landed there.
meshcore>=2.3.8
# 2.3.14+ is required, not merely preferred. Before 2.3.13, send_msg_with_retry
# reported ACKed DMs as failures: it subscribed to the ACK only after send_msg
# returned, and each attempt accepted only its own ACK code, so an ACK that arrived
# right behind MSG_SENT or answered an earlier attempt was ignored (meshcore_py#108).
# 2.3.14 additionally fixes send_cmd's destination-type handling. 2.3.8 first brought
# send_node_discover_req / req_regions_sync (zero-hop neighbor discovery) and the
# bounded, serialized BLE write.
meshcore>=2.3.14
openmeteo-requests>=1.7.2
requests-cache>=1.1.1
retry-requests>=1.0.0
+87
View File
@@ -81,3 +81,90 @@ class TestHelloCommand:
assert call_args is not None
response = call_args[0][1]
assert isinstance(response, str) and len(response) > 0
class TestHelloCommandIncludeSender:
"""Tests for the Hello_Command include_sender setting."""
@staticmethod
def _command(command_mock_bot, include_sender):
command_mock_bot.config.add_section("Hello_Command")
command_mock_bot.config.set("Hello_Command", "enabled", "true")
command_mock_bot.config.set("Hello_Command", "include_sender", str(include_sender).lower())
return HelloCommand(command_mock_bot)
def test_mention_empty_when_setting_off(self, command_mock_bot):
cmd = self._command(command_mock_bot, False)
assert cmd.get_sender_mention(mock_message(sender_id="Bob")) == ""
def test_mention_built_for_channel_sender(self, command_mock_bot):
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id="Bob")) == "@[Bob]"
def test_mention_empty_for_dm(self, command_mock_bot):
"""A DM has one recipient; naming them spends airtime to say nothing."""
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id="Bob", is_dm=True)) == ""
def test_mention_empty_for_unnamed_channel_sender(self, command_mock_bot):
"""message_handler uses this literal when the packet has no name prefix."""
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id="Channel User")) == ""
def test_mention_empty_when_sender_id_missing(self, command_mock_bot):
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id=None)) == ""
def test_mention_strips_brackets_that_would_forge_a_second_mention(self, command_mock_bot):
"""Channel names come off the wire unsanitized (handle_channel_message)."""
cmd = self._command(command_mock_bot, True)
mention = cmd.get_sender_mention(mock_message(sender_id="Bob] hi @[Alice"))
assert mention == "@[Bob hi @Alice]"
assert mention.count("[") == 1 and mention.count("]") == 1
def test_mention_strips_control_characters(self, command_mock_bot):
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id="Bo\nb\x00")) == "@[Bob]"
def test_mention_strips_braces_that_would_break_formatting(self, command_mock_bot):
"""The mention is embedded before .format(bot_name=...) runs."""
cmd = self._command(command_mock_bot, True)
assert cmd.get_sender_mention(mock_message(sender_id="Bo{b}")) == "@[Bob]"
def test_greeting_uses_mention_in_place_of_descriptor(self, command_mock_bot):
cmd = self._command(command_mock_bot, True)
with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]):
with_mention = cmd.get_random_greeting("@[Bob]")
without = cmd.get_random_greeting()
assert "@[Bob]" in with_mention
# The name takes the descriptor's slot rather than riding in front of it.
assert not with_mention.startswith("@[Bob]")
assert with_mention.split()[0] == without.split()[0]
def test_emoji_response_prefixes_mention(self, command_mock_bot):
"""Canned emoji lines have no descriptor slot, so the mention leads."""
cmd = self._command(command_mock_bot, True)
with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]):
result = cmd.get_emoji_response("🖖", "TestBot", "@[Bob]")
assert result.startswith("@[Bob] ")
@pytest.mark.asyncio
async def test_execute_names_channel_sender(self, command_mock_bot):
cmd = self._command(command_mock_bot, True)
msg = mock_message(content="hello", sender_id="Bob")
with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]):
assert await cmd.execute(msg) is True
response = command_mock_bot.command_manager.send_response.call_args[0][1]
assert "@[Bob]" in response
@pytest.mark.asyncio
async def test_execute_drops_mention_that_would_overflow_the_budget(self, command_mock_bot):
"""Nothing downstream truncates, so an over-budget reply is cut on air."""
cmd = self._command(command_mock_bot, True)
msg = mock_message(content="hello", sender_id="B" * 60)
with patch.object(cmd, "get_max_message_length", return_value=60):
with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]):
assert await cmd.execute(msg) is True
response = command_mock_bot.command_manager.send_response.call_args[0][1]
assert "@[" not in response
assert len(response.encode("utf-8")) <= 60
@@ -556,3 +556,45 @@ class TestWildcardOnlyAllowlist:
def test_unset_config_configures_no_allowlist(self):
keys, allow_global = self._cmd_mgr("")
assert bool(keys or allow_global) is False
@pytest.mark.asyncio
async def test_send_channel_message_restores_scope_when_set_raises():
"""A raising set_flood_scope must still restore global flood.
Otherwise the device stays pinned to the region and every later send —
channel replies, DMs, scheduled sends — goes out under that scope.
"""
bot = MagicMock()
bot.logger = Mock()
bot.config = make_config()
bot.connected = True
bot.meshcore = MagicMock()
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager = MagicMock()
bot.channel_manager.get_channel_number = Mock(return_value=0)
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
async def _set_flood_scope(value):
if value != "*":
raise RuntimeError("radio went away")
return MagicMock(type="OK")
set_flood_scope = AsyncMock(side_effect=_set_flood_scope)
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._is_no_event_received = Mock(return_value=False)
cm._handle_send_result = Mock(return_value=True)
assert await cm.send_channel_message("general", "hi", scope="west") is False
scopes_set = [c.args[0] for c in set_flood_scope.await_args_list if c.args]
assert scopes_set == ["#west", "*"]
send_chan_msg.assert_not_awaited()
+32
View File
@@ -187,6 +187,38 @@ class TestFetchAllChannelsCacheLifecycle:
# Should have aborted before fetching all 8 channels
assert call_count < cm.max_channels
assert channels == []
assert cm._cache_valid is False
cm._store_channels_in_db.assert_not_called()
class TestFetchChannelsRetry:
"""A connected node with no returned channels must not look ready (#266)."""
@pytest.mark.asyncio
async def test_retries_empty_scan_then_succeeds(self, cm):
cm.bot.connected = True
channel = {"channel_name": "general", "channel_idx": 0}
cm.fetch_all_channels = AsyncMock(side_effect=[[], [channel]])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await cm.fetch_channels(max_attempts=3, retry_delay=0)
assert result is True
assert cm.fetch_all_channels.await_count == 2
@pytest.mark.asyncio
async def test_exhausted_empty_scans_fail_and_invalidate_cache(self, cm):
cm.bot.connected = True
cm._cache_valid = True
cm.fetch_all_channels = AsyncMock(return_value=[])
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await cm.fetch_channels(max_attempts=3, retry_delay=0)
assert result is False
assert cm.fetch_all_channels.await_count == 3
assert cm._cache_valid is False
assert cm.bot.meshcore.channels == {}
# ---------------------------------------------------------------------------
+102
View File
@@ -0,0 +1,102 @@
"""Tests for modules.commands.contact_command."""
import asyncio
import configparser
from unittest.mock import AsyncMock, MagicMock, Mock
from modules.commands.contact_command import ContactCommand
from tests.conftest import mock_message
PUBKEY = "f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d"
def _make_bot(self_info=None):
bot = MagicMock()
bot.logger = Mock()
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "bot_name", "TestBot")
config.set("Bot", "respond_to_mentions", "also")
config.add_section("Channels")
config.set("Channels", "monitor_channels", "general")
config.set("Channels", "respond_to_dms", "true")
config.add_section("Keywords")
config.add_section("Contact_Command")
config.set("Contact_Command", "enabled", "true")
bot.config = config
bot.translator = MagicMock()
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
bot.command_manager = MagicMock()
bot.command_manager.monitor_channels = ["general"]
bot.meshcore = MagicMock()
bot.meshcore.self_info = self_info
return bot
def _make_command(self_info=None):
cmd = ContactCommand(_make_bot(self_info))
cmd.send_response = AsyncMock(return_value=True)
return cmd
class TestMatching:
def test_matches_bare_keyword(self):
cmd = _make_command()
assert cmd.matches_keyword(mock_message("contact")) is True
def test_does_not_match_with_arguments(self):
cmd = _make_command()
assert cmd.matches_keyword(mock_message("contact me")) is False
def test_matches_configured_alias(self):
bot = _make_bot()
bot.config.set("Contact_Command", "aliases", "card")
cmd = ContactCommand(bot)
assert cmd.matches_keyword(mock_message("card")) is True
def test_non_match_leaves_message_content_untouched(self):
# Regression guard for #267: the keyword scan must not rewrite
# overheard traffic when this command does not match.
cmd = _make_command()
message = mock_message("@[TestBot] wx 98101")
assert cmd.matches_keyword(message) is False
assert message.content == "@[TestBot] wx 98101"
class TestExecute:
def test_sends_contact_card(self):
cmd = _make_command({"public_key": PUBKEY, "name": "TestBot"})
assert asyncio.run(cmd.execute(mock_message("contact"))) is True
cmd.send_response.assert_awaited_once()
assert cmd.send_response.await_args[0][1] == f"<{PUBKEY}:1:TestBot>"
def test_reads_self_info_object(self):
self_info = MagicMock(spec=["public_key", "name"])
self_info.public_key = PUBKEY.upper()
self_info.name = "TestBot"
cmd = _make_command(self_info)
assert asyncio.run(cmd.execute(mock_message("contact"))) is True
assert cmd.send_response.await_args[0][1] == f"<{PUBKEY}:1:TestBot>"
def test_missing_self_info_reports_unavailable(self):
cmd = _make_command(None)
assert asyncio.run(cmd.execute(mock_message("contact"))) is True
assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable"
def test_malformed_public_key_reports_unavailable(self):
cmd = _make_command({"public_key": "nothex", "name": "TestBot"})
asyncio.run(cmd.execute(mock_message("contact")))
assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable"
def test_missing_name_reports_unavailable(self):
cmd = _make_command({"public_key": PUBKEY})
asyncio.run(cmd.execute(mock_message("contact")))
assert cmd.send_response.await_args[0][1] == "commands.contact.unavailable"
class TestEnabledFlag:
def test_disabled_blocks_execution(self):
bot = _make_bot({"public_key": PUBKEY, "name": "TestBot"})
bot.config.set("Contact_Command", "enabled", "false")
cmd = ContactCommand(bot)
assert cmd.can_execute(mock_message("contact")) is False
+22
View File
@@ -868,6 +868,28 @@ class TestTransportReconnect:
assert bot._radio_relinks_in_progress == 0
assert bot.keep_running is False
def test_connect_fails_when_channel_fetch_never_returns_channels(self, tmp_path):
bot = self._make_bot(tmp_path, connection_type="serial")
new_meshcore = MagicMock()
new_meshcore.is_connected = True
new_meshcore.self_info = {}
new_meshcore.disconnect = AsyncMock()
bot.wait_for_contacts = AsyncMock()
bot.channel_manager.fetch_channels = AsyncMock(return_value=False)
bot.setup_message_handlers = AsyncMock()
with patch(
"modules.core.meshcore.MeshCore.create_serial",
AsyncMock(return_value=new_meshcore),
):
assert asyncio.run(bot.connect()) is False
new_meshcore.disconnect.assert_awaited_once()
bot.setup_message_handlers.assert_not_awaited()
assert bot.meshcore is None
assert bot.connected is False
def test_keep_running_through_web_viewer_reboot(self, tmp_path):
bot = self._make_bot(tmp_path)
bot.meshcore = MagicMock()
+137
View File
@@ -0,0 +1,137 @@
"""Tests for the --link-css and --embed-css options in generate_website.py"""
import sys
import pytest
import generate_website
from generate_website import STYLES, generate_builtin_css, generate_html
def _render(**kwargs) -> str:
return generate_html(
bot_name="TestBot",
title="Test Site",
introduction="Test intro",
commands=[],
monitor_channels=[],
channels_data={},
**kwargs,
)
def test_generate_builtin_css_includes_css_variables():
css = generate_builtin_css('default')
assert ':root' in css
assert '--bg-primary' in css
assert '--accent-blue' in css
assert 'body {' in css
def test_generate_builtin_css_includes_style_overrides():
css = generate_builtin_css('minimalist')
assert STYLES['minimalist']['css_overrides'].strip() in css
def test_default_output_has_no_custom_css():
html = _render(style='default')
assert 'Custom CSS overrides' not in html
stylesheets = [line for line in html.splitlines() if 'rel="stylesheet"' in line]
assert len(stylesheets) == 1
assert 'fonts.googleapis.com/css2' in stylesheets[0]
def test_backwards_compatible_call_without_css_params():
html = generate_html("TestBot", "Test Site", "Test intro", [], [], {}, 'default')
assert '<style>' in html
assert '--bg-primary' in html
def test_link_css_follows_builtin_style():
"""The linked stylesheet must load after the <style> block to win the cascade."""
html = _render(style='minimalist', link_css='https://cdn.example.com/overrides.css')
link = '<link rel="stylesheet" href="https://cdn.example.com/overrides.css">'
assert link in html
assert html.index('</style>') < html.index(link) < html.index('</head>')
assert 'fonts.googleapis.com' in html
assert '--bg-primary' in html
def test_link_css_is_escaped():
html = _render(style='default', link_css='style.css?v=1&theme=dark"><script>alert(1)</script>')
assert 'href="style.css?v=1&amp;theme=dark&quot;&gt;&lt;script&gt;' in html
assert '<script>alert(1)' not in html
def test_embed_css_follows_builtin_css():
html = _render(style='default', custom_css=':root {\n --bg-primary: #123456;\n}\n')
style_block = html[html.index('<style>'):html.index('</style>')]
builtin_pos = style_block.index('--bg-primary: #0a0e14')
custom_pos = style_block.index('--bg-primary: #123456')
assert builtin_pos < custom_pos
assert 'Custom CSS overrides' in style_block
def test_embed_and_link_css_together():
html = _render(
style='default',
link_css='overrides.css',
custom_css='.command-card { border-radius: 0; }',
)
embedded_pos = html.index('.command-card { border-radius: 0; }')
link_pos = html.index('<link rel="stylesheet" href="overrides.css">')
assert embedded_pos < html.index('</style>') < link_pos
@pytest.mark.parametrize('style', list(STYLES))
def test_every_style_accepts_custom_css(style):
html = _render(style=style, link_css='overrides.css', custom_css='.x { color: red; }')
assert STYLES[style]['fonts_url'] in html
assert '.x { color: red; }' in html
assert 'href="overrides.css"' in html
assert 'mobile-menu-toggle' in html
def test_main_exits_when_embed_css_is_unreadable(monkeypatch, tmp_path):
def fail_read_config(*args, **kwargs):
raise AssertionError("config should not be read when --embed-css is unreadable")
monkeypatch.setattr(generate_website, 'read_config', fail_read_config)
monkeypatch.setattr(sys, 'argv', ['generate_website.py', '--embed-css', str(tmp_path / 'missing.css')])
with pytest.raises(SystemExit) as exc:
generate_website.main()
assert exc.value.code == 1
def test_main_passes_custom_css_to_samples(monkeypatch, tmp_path):
css_file = tmp_path / 'tweaks.css'
css_file.write_text(':root { --accent-blue: #2563eb; }', encoding='utf-8')
captured = {}
def fake_generate_samples(config_file, link_css=None, custom_css=None):
captured.update(config_file=config_file, link_css=link_css, custom_css=custom_css)
monkeypatch.setattr(generate_website, 'generate_samples', fake_generate_samples)
monkeypatch.setattr(sys, 'argv', [
'generate_website.py', 'my.ini', '--sample',
'--link-css', 'overrides.css', '--embed-css', str(css_file),
])
with pytest.raises(SystemExit) as exc:
generate_website.main()
assert exc.value.code == 0
assert captured == {
'config_file': 'my.ini',
'link_css': 'overrides.css',
'custom_css': ':root { --accent-blue: #2563eb; }',
}
@@ -0,0 +1,237 @@
"""Tests for local command discovery and disabled-command filtering in generate_website.py"""
import configparser
import logging
import os
from types import SimpleNamespace
import pytest
from generate_website import (
filter_commands,
is_command_enabled,
read_config,
resolve_local_commands_dir,
)
def _config(text: str) -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.read_string(text)
return config
def _command(name: str, **attrs):
defaults = {
'name': name,
'keywords': [name],
'category': 'general',
'requires_admin_access': lambda: False,
}
defaults.update(attrs)
return SimpleNamespace(**defaults)
# ---------------------------------------------------------------------------
# is_command_enabled
# ---------------------------------------------------------------------------
def test_no_config_section_means_enabled():
assert is_command_enabled(_command('ping'), _config('')) is True
def test_section_without_enabled_key_means_enabled():
config = _config('[Ping_Command]\ncooldown_seconds = 5\n')
assert is_command_enabled(_command('ping'), config) is True
@pytest.mark.parametrize('raw', ['false', 'no', 'off', '0'])
def test_canonical_enabled_key_disables(raw):
config = _config(f'[Ping_Command]\nenabled = {raw}\n')
assert is_command_enabled(_command('ping'), config) is False
@pytest.mark.parametrize('raw', ['true', 'yes', 'on', '1'])
def test_canonical_enabled_key_enables(raw):
config = _config(f'[Ping_Command]\nenabled = {raw}\n')
assert is_command_enabled(_command('ping'), config) is True
def test_camel_case_name_resolves_its_section():
config = _config('[DadJoke_Command]\nenabled = false\n')
assert is_command_enabled(_command('dadjoke'), config) is False
# The naive "dadjoke".title() spelling must not be what gets read.
assert not config.has_section('Dadjoke_Command')
def test_legacy_alias_in_another_section_disables():
"""[Jokes] joke_enabled lives in a different section than Joke_Command."""
config = _config('[Jokes]\njoke_enabled = false\n')
assert is_command_enabled(_command('joke'), config) is False
@pytest.mark.parametrize('name,section,key', [
('sports', 'Sports_Command', 'sports_enabled'),
('stats', 'Stats_Command', 'stats_enabled'),
('hacker', 'Hacker_Command', 'hacker_enabled'),
('alert', 'Alert_Command', 'alert_enabled'),
# joke/dadjoke accept the same-section spelling too, via their own
# __init__ fallback — see modules/commands/joke_command.py.
('joke', 'Joke_Command', 'joke_enabled'),
('dadjoke', 'DadJoke_Command', 'dadjoke_enabled'),
])
def test_legacy_alias_in_same_section_disables(name, section, key):
config = _config(f'[{section}]\n{key} = false\n')
assert is_command_enabled(_command(name), config) is False
def test_canonical_key_wins_over_legacy_alias():
config = _config('[Joke_Command]\nenabled = true\n[Jokes]\njoke_enabled = false\n')
assert is_command_enabled(_command('joke'), config) is True
def test_unparseable_enabled_value_falls_back_to_enabled():
config = _config('[Ping_Command]\nenabled = maybe\n')
assert is_command_enabled(_command('ping'), config) is True
def test_command_without_a_name_is_enabled():
assert is_command_enabled(SimpleNamespace(), _config('')) is True
# ---------------------------------------------------------------------------
# filter_commands
# ---------------------------------------------------------------------------
def test_filter_commands_drops_disabled_commands():
commands = {'ping': _command('ping'), 'wx': _command('wx')}
config = _config('[Wx_Command]\nenabled = false\n')
assert set(filter_commands(commands, [], config)) == {'ping'}
def test_filter_commands_drops_disabled_local_command():
"""A local command is filtered by the same rules as a built-in one."""
commands = {'sitrep': _command('sitrep')}
config = _config('[Sitrep_Command]\nenabled = false\n')
assert filter_commands(commands, [], config) == {}
def test_filter_commands_keeps_enabled_local_command():
commands = {'sitrep': _command('sitrep')}
assert set(filter_commands(commands, [], _config(''))) == {'sitrep'}
def test_filter_commands_still_drops_admin_and_hidden():
commands = {
'ping': _command('ping'),
'reboot': _command('reboot'),
'secret': _command('secret', category='hidden'),
'sudo': _command('sudo', requires_admin_access=lambda: True),
'silent': _command('silent', keywords=[]),
'quiet': _command('quiet', hidden=True),
}
assert set(filter_commands(commands, ['reboot'], _config(''))) == {'ping'}
# ---------------------------------------------------------------------------
# resolve_local_commands_dir
# ---------------------------------------------------------------------------
def test_resolve_local_commands_dir_returns_none_when_missing(tmp_path):
logger = logging.getLogger('test')
assert resolve_local_commands_dir(_config(''), str(tmp_path), logger) is None
def test_resolve_local_commands_dir_finds_default_location(tmp_path):
expected = tmp_path / 'local' / 'commands'
expected.mkdir(parents=True)
logger = logging.getLogger('test')
result = resolve_local_commands_dir(_config(''), str(tmp_path), logger)
assert result is not None
assert os.path.realpath(result) == os.path.realpath(str(expected))
def test_resolve_local_commands_dir_honors_configured_local_dir(tmp_path):
expected = tmp_path / 'custom' / 'commands'
expected.mkdir(parents=True)
logger = logging.getLogger('test')
result = resolve_local_commands_dir(
_config('[Bot]\nlocal_dir_path = custom\n'), str(tmp_path), logger
)
assert result is not None
assert os.path.realpath(result) == os.path.realpath(str(expected))
def test_resolve_local_commands_dir_rejects_a_file(tmp_path):
"""A file named `commands` is not a plugin directory."""
(tmp_path / 'local').mkdir()
(tmp_path / 'local' / 'commands').write_text('not a directory')
logger = logging.getLogger('test')
assert resolve_local_commands_dir(_config(''), str(tmp_path), logger) is None
# ---------------------------------------------------------------------------
# read_config local overlay
# ---------------------------------------------------------------------------
def _write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
return path
def test_read_config_overlays_local_config(tmp_path):
"""The settings UI writes a local plugin's state to local/config.ini."""
base = _write(tmp_path / 'config.ini', '[Bot]\nbot_name = TestBot\n')
_write(tmp_path / 'local' / 'config.ini', '[Sitrep_Command]\nenabled = false\n')
config = read_config(str(base))
assert config.get('Bot', 'bot_name') == 'TestBot'
assert is_command_enabled(_command('sitrep'), config) is False
def test_local_overlay_wins_over_base(tmp_path):
base = _write(tmp_path / 'config.ini', '[Bot]\n[Wx_Command]\nenabled = true\n')
_write(tmp_path / 'local' / 'config.ini', '[Wx_Command]\nenabled = false\n')
assert is_command_enabled(_command('wx'), read_config(str(base))) is False
def test_read_config_honors_configured_local_dir(tmp_path):
base = _write(tmp_path / 'config.ini', '[Bot]\nlocal_dir_path = custom\n')
_write(tmp_path / 'custom' / 'config.ini', '[Wx_Command]\nenabled = false\n')
assert is_command_enabled(_command('wx'), read_config(str(base))) is False
def test_read_config_without_local_overlay(tmp_path):
base = _write(tmp_path / 'config.ini', '[Bot]\nbot_name = TestBot\n')
config = read_config(str(base))
assert config.get('Bot', 'bot_name') == 'TestBot'
assert is_command_enabled(_command('wx'), config) is True
def test_read_config_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
read_config(str(tmp_path / 'nope.ini'))
+14
View File
@@ -1756,6 +1756,20 @@ class TestAuthenticatedChannelCorrelation:
payload.update(over)
return payload
def test_channel_secrets_reads_meshcore_list_layout(self, handler):
"""meshcore keeps channels in a list; entries without channel_idx use their position."""
self._setup(
handler,
[
{},
{"channel_secret": self.SECRET_1.hex()},
{"channel_idx": 5, "channel_key_hex": self.SECRET_2.hex()},
{"channel_idx": None, "channel_secret": self.SECRET_1},
],
)
assert handler._channel_secrets() == [(1, self.SECRET_1), (5, self.SECRET_2)]
@pytest.mark.asyncio
async def test_zero_snr_uses_authenticated_rf_row(self, handler):
self._setup(
+130 -38
View File
@@ -1,9 +1,11 @@
"""Tests for the host->radio command serializer (_SerializedCommands).
"""Tests for the host->radio command serializer (_serialize_command_frames).
These validate the core mitigation for the firmware corruption / parser-desync
failure mode: every command issued to the radio is serialized to one in-flight
failure mode: every frame written to the radio is serialized to one in-flight
companion frame at a time and paced by a minimum inter-command interval, so the
firmware's single-threaded serial loop cannot be overrun.
firmware's single-threaded serial loop cannot be overrun. The lock covers a
frame and its immediate reply only, so commands that wait for ACKs or remote
responses don't block other senders.
"""
import asyncio
@@ -11,8 +13,10 @@ import time
from pathlib import Path
import pytest
from meshcore.commands import CommandHandler
from meshcore.events import Event, EventDispatcher, EventType
from modules.core import MeshCoreBot, _SerializedCommands
from modules.core import MeshCoreBot, _serialize_command_frames
def _make_bot(tmp_path: Path, min_interval_ms: int = 30) -> MeshCoreBot:
@@ -37,17 +41,15 @@ monitor_channels = #general
class FakeCommands:
"""Stand-in for meshcore.commands with an async command + passthrough attrs."""
not_callable = 42
"""Stand-in for meshcore.commands: every command writes through send()."""
def __init__(self):
self.active = 0
self.max_active = 0
self.calls = 0
self.frames = []
async def do_work(self, delay: float = 0.02):
self.calls += 1
async def send(self, data, expected_events=None, timeout=None, delay=0.01):
self.frames.append(data)
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
@@ -56,8 +58,11 @@ class FakeCommands:
finally:
self.active -= 1
def sync_method(self):
return "sync"
async def send_and_wait_for_reply(self, data, wait: float):
"""Like send_msg_with_retry: write one frame, then wait for a remote reply."""
await self.send(data)
await asyncio.sleep(wait)
return "replied"
class FakeMeshcore:
@@ -75,60 +80,147 @@ def test_min_interval_negative_clamped_to_zero(tmp_path):
assert bot._radio_cmd_min_interval == 0.0
async def test_serializes_concurrent_commands(tmp_path):
"""Only one wrapped command may run at a time."""
async def test_serializes_concurrent_frames(tmp_path):
"""Only one frame may be in flight at a time."""
bot = _make_bot(tmp_path, min_interval_ms=0) # isolate mutex from pacing
fake = FakeCommands()
proxy = _SerializedCommands(bot, fake)
_serialize_command_frames(bot, fake)
await asyncio.gather(*(proxy.do_work(delay=0.01) for _ in range(10)))
await asyncio.gather(*(fake.send(b"\x01") for _ in range(10)))
assert fake.calls == 10
assert len(fake.frames) == 10
assert fake.max_active == 1 # never more than one in-flight frame
async def test_pacing_enforces_minimum_gap(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=50)
fake = FakeCommands()
proxy = _SerializedCommands(bot, fake)
_serialize_command_frames(bot, fake)
start = time.monotonic()
for _ in range(5):
await proxy.do_work(delay=0.0)
await fake.send(b"\x01", delay=0.0)
elapsed = time.monotonic() - start
# 5 commands => 4 enforced gaps of ~50ms (first call is not delayed).
# 5 frames => 4 enforced gaps of ~50ms (first frame is not delayed).
assert elapsed >= 0.18
async def test_non_coroutine_attributes_pass_through(tmp_path):
bot = _make_bot(tmp_path)
fake = FakeCommands()
proxy = _SerializedCommands(bot, fake)
assert proxy.not_callable == 42
assert proxy.sync_method() == "sync"
async def test_wrapped_command_returns_value(tmp_path):
async def test_wrapped_send_returns_value(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
proxy = _SerializedCommands(bot, fake)
_serialize_command_frames(bot, fake)
assert await proxy.do_work(delay=0.0) == "ok"
assert await fake.send(b"\x01", delay=0.0) == "ok"
def test_install_command_serializer_wraps_and_is_idempotent(tmp_path):
bot = _make_bot(tmp_path)
async def test_reply_wait_does_not_hold_the_lock(tmp_path):
"""A command waiting for a remote reply must not block other frames."""
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
waiting = asyncio.create_task(fake.send_and_wait_for_reply(b"\x02", wait=1.0))
await asyncio.sleep(0.05) # first frame written, now waiting for the reply
start = time.monotonic()
assert await fake.send(b"\x03") == "ok"
assert time.monotonic() - start < 0.5
assert not waiting.done()
waiting.cancel()
async def test_send_msg_with_retry_ack_wait_does_not_block_other_commands(tmp_path):
"""meshcore's own retry loop sends through the wrapped send(), outside the lock."""
bot = _make_bot(tmp_path, min_interval_ms=0)
dispatcher = EventDispatcher()
await dispatcher.start()
try:
handler = CommandHandler()
handler.set_dispatcher(dispatcher)
contact = {"public_key": "ab" * 32, "out_path_len": 0, "out_path": ""}
handler._get_contact_by_prefix = lambda prefix: contact
frames = []
async def radio(data):
data = bytes(data)
frames.append(data[0])
if data[0] == 0x02: # CMD_SEND_TXT_MSG: accepted, but never ACKed
await dispatcher.dispatch(Event(
EventType.MSG_SENT,
{"type": 0, "expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 1000},
))
else:
await dispatcher.dispatch(Event(EventType.OK, {}))
handler._sender_func = radio
_serialize_command_frames(bot, handler)
dm = asyncio.create_task(handler.send_msg_with_retry(contact, "hi", max_attempts=1))
await asyncio.sleep(0.1) # DM sent, waiting for its ACK
start = time.monotonic()
result = await asyncio.wait_for(handler.send(b"\x14", [EventType.OK]), timeout=0.5)
assert result.type == EventType.OK
assert time.monotonic() - start < 0.5
assert not dm.done()
assert await dm is None # no ACK arrived
assert frames == [0x02, 0x14]
finally:
await dispatcher.stop()
async def test_radio_session_keeps_a_sequence_together(tmp_path):
"""Frames from another task wait until the session ends; frames inside it don't deadlock."""
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
async def other_sender():
await asyncio.sleep(0.01)
await fake.send(b"other", delay=0.0)
other = asyncio.create_task(other_sender())
async with bot.radio_session():
await fake.send(b"set-scope")
await asyncio.sleep(0.05) # the other task tries to send here
await fake.send(b"send")
await fake.send(b"restore")
await other
assert fake.frames == [b"set-scope", b"send", b"restore", b"other"]
async def test_radio_session_is_reentrant(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
async def nested():
async with bot.radio_session():
async with bot.radio_session():
await fake.send(b"\x01")
await asyncio.wait_for(nested(), timeout=1.0)
assert fake.frames == [b"\x01"]
async def test_install_command_serializer_wraps_and_is_idempotent(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
bot.meshcore = FakeMeshcore()
cmds = bot.meshcore.commands
bot._install_command_serializer()
wrapped = bot.meshcore.commands
assert isinstance(wrapped, _SerializedCommands)
wrapped = cmds.send
assert getattr(wrapped, "_radio_serialized", False)
assert bot.meshcore.commands is cmds # wrapped in place
# Re-installing must not double-wrap.
# Re-installing must not double-wrap (a nested non-reentrant lock would deadlock).
bot._install_command_serializer()
assert bot.meshcore.commands is wrapped
assert cmds.send is wrapped
assert await asyncio.wait_for(cmds.send(b"\x01"), timeout=1.0) == "ok"
def test_install_command_serializer_noop_without_meshcore(tmp_path):
+2 -2
View File
@@ -1,8 +1,8 @@
"""Tests for zero-hop neighbor discovery (modules/neighbors_discovery.py).
Ported from the meshcore-packet-capture project's test_neighbors.py, minus the
device-lock tests (the bot serialises radio commands globally in
modules/core.py _SerializedCommands, so the module takes no lock of its own).
device-lock tests (the bot serialises radio frames globally in
modules/core.py _serialize_command_frames, so the module takes no lock of its own).
"""
from __future__ import annotations
+4
View File
@@ -1347,6 +1347,10 @@
},
"cmd": {
"description": "List available commands"
},
"contact": {
"help": "Display the bot's contact information",
"unavailable": "Contact info unavailable: the radio has not reported its identity yet."
}
},
"elapsed": {