From b59fa9cec902c16dcc298cd954700983a6976cb5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 28 Jun 2026 13:28:23 -0700 Subject: [PATCH] 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. --- config.ini.example | 11 ++++- docs/command-reference.md | 29 ++++++++++++- docs/data-retention.md | 2 +- docs/index.md | 1 + docs/service-plugins.md | 1 + docs/web-viewer.md | 2 +- docs/worldcup.md | 67 +++++++++++++++++++++++++++++++ modules/commands/stats_command.py | 6 +-- modules/web_viewer/app.py | 48 +++++++++++----------- tests/test_stats_command.py | 33 ++++++++++++--- 10 files changed, 163 insertions(+), 37 deletions(-) create mode 100644 docs/worldcup.md diff --git a/config.ini.example b/config.ini.example index 7366432..e8aac31 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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 diff --git a/docs/command-reference.md b/docs/command-reference.md index 7f87b7d..37629a0 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -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 ` - 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 diff --git a/docs/data-retention.md b/docs/data-retention.md index 0fbe3de..e1aad68 100644 --- a/docs/data-retention.md +++ b/docs/data-retention.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 9e7bfa6..c7eca1f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 | diff --git a/docs/service-plugins.md b/docs/service-plugins.md index 5d26d86..668c1d7 100644 --- a/docs/service-plugins.md +++ b/docs/service-plugins.md @@ -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 diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 6917e4d..152aa45 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -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 diff --git a/docs/worldcup.md b/docs/worldcup.md new file mode 100644 index 0000000..9baa391 --- /dev/null +++ b/docs/worldcup.md @@ -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. diff --git a/modules/commands/stats_command.py b/modules/commands/stats_command.py index 3db176f..d895486 100644 --- a/modules/commands/stats_command.py +++ b/modules/commands/stats_command.py @@ -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') diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 5d29500..0804949 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -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(""" diff --git a/tests/test_stats_command.py b/tests/test_stats_command.py index 9c68b83..65f7fc5 100644 --- a/tests/test_stats_command.py +++ b/tests/test_stats_command.py @@ -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