feat(config, docs): enhance rain command and stats collection configuration

- Updated the `[Rain_Command]` section in `config.ini.example` to include support for snow alongside rain, improving the command's functionality.
- Enhanced documentation for the rain command to reflect the new snow alias and clarify response behavior based on the selected keyword.
- Added a new `collect_stats` option in the `[Stats_Command]` section, allowing stats collection to be enabled independently of the user-facing command, with updated documentation to explain its behavior.
- Improved the web viewer documentation to clarify how stats are collected and displayed, ensuring users understand the configuration options.
This commit is contained in:
agessaman committed 2026-06-28 13:28:23 -07:00
1 parent b8decd8d66
commit b59fa9cec9
10 files changed
+163 -37

No files matched your search

+9 -2
View File
@@ -778,7 +778,8 @@ enabled = true
[Rain_Command]
# Minute-level rain nowcast ("rain starting in ~20min") via Open-Meteo's
# 15-minutely precipitation. Worldwide, no API key. Keywords: rain, nowcast.
# 15-minutely precipitation. Worldwide, no API key. Keywords: rain, nowcast, snow.
# (snow leads the reply with snowfall depth; rain/nowcast lead with rain amount.)
# Usage: rain [city|zipcode|lat,lon] (no location -> companion, then bot location)
enabled = true
# How far ahead to look, in minutes (default: 120)
@@ -875,7 +876,8 @@ api_timeout = 10
cache_ttl_minutes = 360
[Stats_Command]
# Enable or disable the stats command (true/false)
# Enable or disable the user-facing stats command (true/false)
# This only controls whether the bot responds to the "stats" command.
enabled = true
# Data retention settings
@@ -889,6 +891,11 @@ data_retention_days = 7
auto_cleanup = true
# Stats collection settings
# Collect message, command, and path stats for the web viewer dashboard
# true: Populate stats tables even if the stats command is disabled
# false: Disable stats table writes
collect_stats = true
# Track all incoming messages (not just commands)
# true: Record all messages for comprehensive stats
# false: Only record command executions
+28 -1
View File
@@ -246,12 +246,13 @@ aqi help
Minute-level rain nowcast — tells you when precipitation is about to **start** or **stop** in the next couple hours, using Open-Meteo's 15-minutely precipitation forecast. Works worldwide with no API key.
**Aliases:** `nowcast`
**Aliases:** `nowcast`, `snow`
**Usage:**
```
rain [city|zipcode|lat,lon]
nowcast [city|zipcode|lat,lon]
snow [city|zipcode|lat,lon]
```
**Examples:**
@@ -260,6 +261,7 @@ rain
rain seattle
rain 98101
rain 47.6,-122.3
snow denver
```
**Response:** A single line describing the upcoming precipitation, for example:
@@ -268,6 +270,8 @@ rain 47.6,-122.3
- `🌧️ Heavy rain steady for 2h+ in Seattle` — raining now, no break in the window
- `☀️ No rain expected in next 2h for Seattle` — dry through the window
The keyword sets which precipitation the answer leads with: `rain`/`nowcast` lead with rain (liquid amount), while `snow` leads with snowfall (reported as depth, e.g. `🌨️ Snow starting in ~40min (~1.5 in snow)`). Either keyword still mentions the other type when it's in the window, and snow/ice changeovers are called out. Bare countries or US states (e.g. `rain france`, `snow texas`) default to the region's capital with a heads-up, since one centroid isn't representative.
When no location is given, uses the sender's companion location if known, then the bot's configured location.
**Configuration:** `[Rain_Command]` — `enabled`, `window_minutes` (how far ahead to look, default 120), `precip_threshold_mm` (sensitivity, default 0.1), and optional `default_lat`/`default_lon`. Temperature/precipitation source units are shared via `[Weather]` (`weather_model` is honored).
@@ -684,6 +688,29 @@ sports mlb
**Response:** Current scores and game information for the requested teams or league.
> World Cup scores are also available year-round here, e.g. `sports fifa`.
---
### `wc` or `worldcup`
FIFA World Cup scores and schedule. Responds **only while a World Cup (men's or women's) is actually in progress** — the active tournament is auto-detected from the ESPN schedule, so no dates need to be configured. Outside a tournament it replies that none is in progress.
**Usage:**
- `wc` - Live/most recent scores and upcoming fixtures
- `wc <nation>` - Focus on a specific nation's match
**Examples:**
```
wc
worldcup
wc argentina
```
**Response:** Current scores, match state, and upcoming fixtures for the active tournament.
**Configuration:** `[Worldcup_Command]` — `enabled`, `api_timeout`, `cache_ttl_minutes`. For proactive live match announcements posted to a channel, see the [World Cup Live Service](service-plugins.md).
---
## MeshCore Utility Commands
+1 -1
View File
@@ -15,7 +15,7 @@ purging_log_retention_days = 90
mesh_connections_retention_days = 7
```
Stats tables (message_stats, command_stats, path_stats) use **`[Stats_Command]`** `data_retention_days` (default 7); the scheduler runs that cleanup daily as well. Stats are **collected** when the stats command is enabled, or when the optional **`collect_stats = true`** is set under `[Stats_Command]` (so the web viewer dashboard can show message/command/path stats even if the `stats` chat command is disabled).
Stats tables (message_stats, command_stats, path_stats) use **`[Stats_Command]`** `data_retention_days` (default 7); the scheduler runs that cleanup daily as well. Stats are **collected** by default with **`collect_stats = true`** under `[Stats_Command]`, even if the user-facing `stats` chat command is disabled with `enabled = false`. Set **`collect_stats = false`** only if you want to stop writing those dashboard stats tables.
## Tables and defaults
+1
View File
@@ -40,3 +40,4 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services
| [Map Uploader](map-uploader.md) | Uploading to map.meshcore.dev |
| [Weather Service](weather-service.md) | Scheduled weather and alerts |
| [Repeater Prefix Collision Service](repeater-prefix-collision-service.md) | Detect repeater prefix collisions |
| [World Cup](worldcup.md) | World Cup command and live match announcements |
+1
View File
@@ -13,6 +13,7 @@ Service plugins extend the bot with background services that run alongside the m
| Webhook Service (`Webhook`) | Accept inbound HTTP POST payloads and relay to mesh channels or DMs |
| [Earthquake Service](earthquake-service.md) | Earthquake alerts for a configured region (USGS API, defaults: California) |
| [Repeater Prefix Collision Service](repeater-prefix-collision-service.md) | Alerts when a newly heard repeater prefix collides with an existing repeater prefix |
| [World Cup Live Service](worldcup.md) | Proactive FIFA World Cup match announcements (auto-detects the active tournament) |
## Enabling a plugin
+1 -1
View File
@@ -178,7 +178,7 @@ curl http://localhost:5000/api/stats
The viewer uses the same database as the bot by default (`[Bot] db_path`, typically `meshcore_bot.db`). That single file holds repeater contacts, mesh graph, packet stream, and other data so the viewer can show everything.
**Dashboard stats** (message/command counts, top users, etc.) come from the stats tables (`message_stats`, `command_stats`, `path_stats`). To populate these when the `stats` chat command is disabled, you can set the optional config under `[Stats_Command]`: `collect_stats = true`.
**Dashboard stats** (message/command counts, top users, etc.) come from the stats tables (`message_stats`, `command_stats`, `path_stats`). Stats collection is enabled by default with `[Stats_Command] collect_stats = true`, even if the user-facing `stats` chat command is disabled with `enabled = false`. Set `collect_stats = false` only if you want to stop writing those dashboard stats tables.
## Migrating from a separate web viewer database
+67
View File
@@ -0,0 +1,67 @@
# World Cup
The bot has two World Cup features that work together, both driven by ESPN data and active **only while a FIFA World Cup (men's or women's) is actually in progress**. The active tournament is auto-detected from the ESPN schedule, so no dates need to be configured — outside a tournament both features idle.
- **`wc` / `worldcup` command** — on-demand scores and fixtures (see the [Command Reference](command-reference.md#wc-or-worldcup)).
- **World Cup Live Service** — proactive match announcements posted to a channel.
World Cup scores are also available year-round through the regular [`sports` command](command-reference.md#sports), e.g. `sports fifa`.
## Command (`[Worldcup_Command]`)
```ini
[Worldcup_Command]
# Enable or disable the World Cup command (true/false). Commands: wc / worldcup
enabled = true
# ESPN API timeout in seconds
api_timeout = 10
# How long (minutes) to cache season detection and nation roster lookups,
# to avoid repeated ESPN requests. Default: 360 (6 hours).
cache_ttl_minutes = 360
```
## Live Service (`[Worldcup_Service]`)
Posts proactive messages to a channel as matches progress, for example:
```
Group E: Côte d'Ivoire 0, Ecuador 0 (half-time)
Group J: Argentina 1, Algeria 0 — 23' Lionel Messi
```
The service only runs while a tournament is in progress and idles otherwise. It pairs with the `wc`/`worldcup` command. Enable it in `[Worldcup_Service]`:
```ini
[Worldcup_Service]
enabled = false
# Optional regional TC_FLOOD scope for mesh channel posts from this service.
# flood_scope = #west
# Channel to post live updates to
channel = #general
# Poll interval (ms) while a tournament is active but no match is in progress. Default: 60000
poll_interval = 60000
# Faster poll interval (ms) used while at least one match is LIVE. ESPN's scoreboard is
# edge-cached ~15-20s, so polling faster yields no fresher data. Default: 20000
live_poll_interval = 20000
# Idle interval (seconds) used when no tournament is in progress. Default: 1800 (30 min)
idle_interval = 1800
# Which match events to announce (each true/false)
announce_kickoff = true
announce_goals = true
# Post a follow-up when a previously-announced goal is overturned by VAR. Requires announce_goals.
announce_disallowed = true
# Announce red cards
announce_red_cards = true
# Announce yellow cards too. OFF by default — yellows are frequent and can flood a mesh channel.
announce_yellow_cards = false
```
See `config.ini.example` for the full annotated list of options.
+3 -3
View File
@@ -47,11 +47,11 @@ class StatsCommand(BaseCommand):
self.stats_enabled = self.get_config_value('Stats_Command', 'enabled', fallback=None, value_type='bool')
if self.stats_enabled is None:
self.stats_enabled = self.get_config_value('Stats_Command', 'stats_enabled', fallback=True, value_type='bool')
# Optional: collect_stats (defaults to stats_enabled). When true, messages/commands/paths
# are recorded for the web viewer dashboard even if enabled = false.
# Optional: collect_stats controls dashboard/stat table writes independently
# of whether the user-facing stats command is enabled.
self.collect_stats = self.get_config_value('Stats_Command', 'collect_stats', fallback=None, value_type='bool')
if self.collect_stats is None:
self.collect_stats = self.stats_enabled
self.collect_stats = True
self.data_retention_days = self.get_config_value('Stats_Command', 'data_retention_days', fallback=7, value_type='int')
self.auto_cleanup = self.get_config_value('Stats_Command', 'auto_cleanup', fallback=True, value_type='bool')
self.track_all_messages = self.get_config_value('Stats_Command', 'track_all_messages', fallback=True, value_type='bool')
+24 -24
View File
@@ -4160,6 +4160,30 @@ class BotDataViewer:
cursor.execute(query)
stats['top_users'] = [{'user': row[0], 'count': row[1]} for row in cursor.fetchall()]
# Top channels by message count - filter by time window
if top_channels_window == '24h':
time_filter = "AND timestamp > strftime('%s', 'now', '-24 hours')"
elif top_channels_window == '7d':
time_filter = "AND timestamp > strftime('%s', 'now', '-7 days')"
elif top_channels_window == '30d':
time_filter = "AND timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users
FROM message_stats
WHERE channel IS NOT NULL {time_filter}
GROUP BY channel
ORDER BY message_count DESC
LIMIT 10
"""
cursor.execute(query)
stats['top_channels'] = [
{'channel': row[0], 'messages': row[1], 'users': row[2]}
for row in cursor.fetchall()
]
if 'command_stats' in tables:
cursor.execute("SELECT COUNT(*) FROM command_stats")
stats['total_commands'] = cursor.fetchone()[0]
@@ -4240,30 +4264,6 @@ class BotDataViewer:
else:
stats['bot_reply_rate_30d'] = 0
# Top channels by message count - filter by time window
if top_channels_window == '24h':
time_filter = "AND timestamp > strftime('%s', 'now', '-24 hours')"
elif top_channels_window == '7d':
time_filter = "AND timestamp > strftime('%s', 'now', '-7 days')"
elif top_channels_window == '30d':
time_filter = "AND timestamp > strftime('%s', 'now', '-30 days')"
else: # 'all'
time_filter = ""
query = f"""
SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users
FROM message_stats
WHERE channel IS NOT NULL {time_filter}
GROUP BY channel
ORDER BY message_count DESC
LIMIT 10
"""
cursor.execute(query)
stats['top_channels'] = [
{'channel': row[0], 'messages': row[1], 'users': row[2]}
for row in cursor.fetchall()
]
# Path statistics (if path_stats table exists)
if 'path_stats' in tables:
cursor.execute("""
+28 -5
View File
@@ -3,7 +3,7 @@
import configparser
import sqlite3
from contextlib import contextmanager
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -45,7 +45,7 @@ def _make_db_manager():
return db
def _make_bot(enabled=True):
def _make_bot(enabled=True, collect_stats=True):
bot = MagicMock()
bot.logger = Mock()
config = configparser.ConfigParser()
@@ -57,7 +57,8 @@ def _make_bot(enabled=True):
config.add_section("Keywords")
config.add_section("Stats_Command")
config.set("Stats_Command", "enabled", str(enabled).lower())
config.set("Stats_Command", "collect_stats", "true")
if collect_stats is not None:
config.set("Stats_Command", "collect_stats", str(collect_stats).lower())
bot.config = config
bot.translator = MagicMock()
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
@@ -141,6 +142,18 @@ class TestStatsCommandEnabled:
cmd = StatsCommand(bot)
assert cmd.stats_enabled is False
def test_collect_stats_defaults_true_when_command_disabled(self):
bot = _make_bot(enabled=False, collect_stats=None)
cmd = StatsCommand(bot)
assert cmd.stats_enabled is False
assert cmd.collect_stats is True
def test_collect_stats_explicit_false_opt_out(self):
bot = _make_bot(enabled=True, collect_stats=False)
cmd = StatsCommand(bot)
assert cmd.stats_enabled is True
assert cmd.collect_stats is False
# ---------------------------------------------------------------------------
# record_message
@@ -168,6 +181,15 @@ class TestRecordMessage:
# Should return early without error
cmd.record_message(msg)
def test_record_message_explicit_collect_stats_false_does_not_insert(self):
bot = _make_bot(enabled=True, collect_stats=False)
cmd = StatsCommand(bot)
msg = mock_message(content="hello", channel="general")
cmd.record_message(msg)
with bot.db_manager.connection() as conn:
count = conn.execute("SELECT COUNT(*) FROM message_stats").fetchone()[0]
assert count == 0
def test_record_message_disabled_track_all(self):
bot = _make_bot(enabled=True)
cmd = StatsCommand(bot)
@@ -298,12 +320,13 @@ class TestRecordPathStats:
class TestExecuteStats:
def test_execute_disabled_returns_false(self):
import asyncio
bot = _make_bot(enabled=False)
bot = _make_bot(enabled=False, collect_stats=None)
cmd = StatsCommand(bot)
cmd.stats_enabled = False
cmd.send_response = AsyncMock(return_value=True)
msg = mock_message(content="stats", channel="general")
result = asyncio.run(cmd.execute(msg))
assert result is False
cmd.send_response.assert_awaited_once()
def test_execute_enabled_returns_true(self):
import asyncio