feat(region): region-code monitoring, warnings, and Radio-page scope settings (#279, #283)

Classify every channel message's flood scope, tally it per channel per day,
and optionally warn senders whose messages carry no region code. Warnings are
off by default and start in dry run; they fire only on positive RF evidence of
an unscoped FLOOD, and are fenced by a per-sender cooldown, a mesh-wide
cooldown and a daily cap read from the database.

Adds Region Scopes and Default Region Scope cards to the Radio page so the
bot's own scopes and the radio's firmware default can be set from the web
viewer.

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
agessaman committed 2026-09-20 10:47:24 -07:00
commit c573755324
29 files changed
+5152 -35

No files matched your search

+65
View File
@@ -8,6 +8,62 @@ semantic versioning.
### Added
- Region-code monitoring and an optional automatic warning to senders whose
channel messages carry no regional flood scope (#279). The bot classifies
every channel message it hears as scoped, global (unscoped) or unknown and
tallies the result per channel per day, which costs no airtime and is on by
default. A new `Settings -> Region Warnings` page in the web viewer shows how
much unscoped traffic the mesh actually carries before any decision to spend
airtime on it.
Warnings themselves are off by default and start in dry run, where every
decision is logged and consumes the same cooldowns and daily cap it would
when live, so the log is a true preview rather than an upper bound. A sender
must send `min_unscoped_messages` confirmed-unscoped messages, and the
warning is fenced by a per-sender cooldown, a mesh-wide cooldown and a daily
cap, all read from the database so a restart cannot release a burst. Warnings
fire only on positive RF evidence of an unscoped FLOOD; a message the radio
could not classify is never warned about. Channel-delivered warnings are sent
at global scope, because a scoped reply could not reach someone outside the
region. Configure it in `[Region_Warnings]`; retention is governed by
`[Data_Retention] region_warning_retention_days`.
A channel sender is a display name, not an identity — MeshCore's channel
messages carry no public key — so DM warnings only go to a name the radio
already holds a contact for, and a message with no `Name: ` prefix is counted
but never warned. Warnings dropped for want of a contact are counted and
shown on the page, so a bot that keeps no contacts reports that rather than
showing an empty log beside a status card claiming it is sending.
`docs/region-warnings.md` says all of this plainly rather than implying the
bot knows who it is talking to.
- The Radio page's Node Settings card now reads and writes the radio's own
**Default Region Scope** (#283), beside the path hash size. This is the
firmware setting (`NodePrefs.default_scope_name` / `default_scope_key`) the
radio falls back to for any send the bot does not scope itself. Firmware
without the setting is reported as not having answered rather than shown as
an empty field, and a stored key that is not the stored name's hash is
flagged, because the radio routes by the key and the name beside it is only a
label. Clearing it sends the firmware's own bare-frame form rather than the
meshcore library's reset helpers, none of which can clear the field: `None`
raises, `""` earns ILLEGAL_ARG, and `"*"` only works through a padding
off-by-one. The card says plainly that the bot leaves the radio in
forced-unscoped mode after any scoped send, so this default stops applying
until the bot scopes another one.
- A **Region Scopes** card on the web viewer's Radio page sets the bot's own
regional flood scopes (#283), so `[Channels] flood_scopes` and
`outgoing_flood_scope_override` no longer have to be edited by hand. It
writes `config.ini` and queues a hot config reload, then polls that reload and
reports what the bot actually did — including saying plainly when nothing
picked the change up. Scope names are normalized on save the way the bot
normalizes them (`west` becomes `#west`), and a name containing `,`, `%` or an
inner `#` is refused before anything is written, because those are the three
characters that do not survive a trip through a comma-separated INI value read
with interpolation on. Per-channel `flood_scope.<channel>` entries are listed
read-only beside the default, since they are the reason a channel can ignore
it.
- `docs/develop-command-scripts.md` walks through writing a command plugin that
integrates correctly with the bot framework, including developing against
`[Bot] local_dir_path` (#259).
@@ -49,6 +105,15 @@ semantic versioning.
### Fixed
- `outgoing_flood_scope_override = none` is now read as global flood on the
send path, as it already was everywhere else. `send_channel_message` tested
the raw value against a fixed tuple, so the lowercase spelling became the
region `#none` and sent scoped.
- Striped and hovered table rows in the web viewer's dark mode no longer render
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
+80
View File
@@ -286,6 +286,11 @@ respond_to_dms = true
# Code default when omitted: 64. This example uses 7 for a tighter local mesh.
max_response_hops = 7
# The Radio page in the web viewer edits flood_scopes and
# outgoing_flood_scope_override for you and reloads the bot, so you only need to
# set them here if you prefer the file. Per-channel flood_scope.<channel> entries
# below are shown there read-only and still have to be edited here.
# List of region scopes (comma-separated) that the bot will reply to when messages arrive on those scopes.
# Replies mirror scope via RF correlation (TC_FLOOD / GRP_TXT); stale ADVERT/other packets are not used for scope.
# Responses will be sent with the same scope hash when correlation succeeds.
@@ -309,6 +314,77 @@ max_response_hops = 7
# flood_scope.general = #west
# flood_scope.weather = #sea
# flood_scope.#local = *
[Region_Warnings]
# Regional flood scope ("region code") monitoring, and an optional automatic
# note to senders who never set one.
#
# A MeshCore client with no region configured sends every channel message as a
# plain FLOOD, which every repeater on the mesh rebroadcasts. A client with a
# region sends a TC_FLOOD whose transport code confines it to repeaters holding
# that region key. This section is about the first kind.
#
# Tallying is free and on by default: the bot classifies each channel message it
# hears as scoped, global (unscoped) or unknown and counts it per channel per
# day. The Region Warnings page in the web viewer shows the result. Nothing is
# transmitted unless you also set enabled = true below.
#
# A message is only ever counted as global on positive RF evidence, correlated
# to that message, that it was an unscoped FLOOD. When the radio did not witness
# enough to tell, it counts as unknown and can never earn a warning.
#
# Note that a channel sender is a display name, not an identity: MeshCore's
# channel messages carry no public key, so anyone with the channel key can send
# under any name. DM warnings therefore only go to a name the radio already has
# a contact for, and the ones dropped for that reason are counted on the Region
# Warnings page. See docs/region-warnings.md.
track_traffic = true
# Send a warning to senders whose messages are confirmed unscoped.
# This spends airtime automatically. Off by default.
enabled = false
# Preview mode. While true, warnings are decided and logged exactly as they
# would be sent — including consuming the cooldowns and the daily cap — but
# nothing is transmitted. Watch the Region Warnings page for a while before
# turning this off.
dry_run = true
# How the warning reaches the sender:
# dm — a direct message to the sender (default; costs the least airtime).
# Only sent to a name the radio already has a contact for.
# channel — a reply on the channel they posted to, always at global scope,
# because a scoped reply would not reach someone outside your region
delivery = dm
# Only warn about messages on these channels (comma-separated, # optional).
# Leave empty to consider every channel the bot hears.
# channels = general, public
# The warning text. {sender} and {channel} are substituted. Keep it short: a DM
# body is 158 bytes and a channel reply is smaller still, and anything longer is
# truncated. For delivery = channel, include @[{sender}] so they see it.
# No '%' — configparser reads this file with interpolation on, so a bare percent
# sign breaks every later read of the section.
# message = Heads up: your messages have no region code, so they flood the whole mesh. Setting one in your MeshCore app keeps things quiet. Thanks!
# How many confirmed-unscoped messages a sender must send before they earn a
# warning, so one stray global flood does not cost anyone a DM. The run resets
# after 24 hours of quiet, when the sender posts a scoped message, and on
# restart.
min_unscoped_messages = 3
# Do not warn the same sender again within this many hours, counting failed
# attempts as well as delivered ones. 0 disables.
per_sender_cooldown_hours = 168
# Minimum gap between warnings to anyone, in minutes. 0 disables.
mesh_cooldown_minutes = 30
# Hard ceiling on warning attempts per local day, failures included. 0 means
# unlimited (not recommended).
max_warnings_per_day = 6
[Banned_Users]
# List of banned sender names (comma-separated). Matching is prefix (starts-with):
# "Awful Username" also matches "Awful Username 🍆". No bot responses in channels or DMs.
@@ -1031,6 +1107,10 @@ mesh_connections_retention_days = 7
# grows very slowly and a long window is cheap. The neighbor_links aggregate that
# the mesh graph reads is never pruned.
neighbor_observations_retention_days = 365
#
# Regional flood scope tallies and region-warning events (see [Region_Warnings]).
# Both are tiny — one tally row per channel per day — so a long window is cheap.
region_warning_retention_days = 90
[Path_Command]
# Enable or disable the path command
enabled = true
+15
View File
@@ -114,6 +114,21 @@ outgoing_flood_scope_override = #west
flood_scopes = #west
```
### Managing region scopes from the web viewer
The **Region Scopes** card on the web viewer's **Radio** page edits both keys
for you. It writes `[Channels]` in `config.ini` and queues a config reload, so
changes take effect **without restarting the bot**, and it reports what the
reload did rather than only that the file was written.
It is an editor for the same two keys, not a separate store, so values set by
hand and values set in the UI are the same thing. Scope names are normalized on
save the way the bot normalizes them (`west` is stored as `#west`), and a name
containing `,`, `%` or an inner `#` is refused before anything is written.
Per-channel `flood_scope.<channel>` entries are shown read-only there; edit
those in `config.ini`.
### Public channel guard
The bot **refuses to start** if `monitor_channels` includes the Public channel, unless an explicit override key is set in `[Bot]`. This prevents accidental bot deployments on the shared channel that is visible to all mesh users by default.
+133
View File
@@ -0,0 +1,133 @@
# Region warnings
MeshCore puts a channel message on the air in one of two ways.
An ordinary `FLOOD` is rebroadcast by every repeater that hears it, anywhere on the mesh. A `TC_FLOOD` carries a transport code derived from a region key — what the apps call a **region code** — and only repeaters holding that key pass it on.
A client with no region configured therefore floods the entire mesh with every message it sends. On a busy mesh that is most of the airtime nobody asked for.
This feature does two separate things:
- **Counts** how many of the channel messages your bot hears were unscoped, per channel, per day. Free, on by default, transmits nothing.
- **Warns** the senders, when you explicitly turn that on. Spends airtime automatically, so it is off by default and starts in dry run.
The Web Viewer page at **Settings → Region Warnings** shows both.
## How a message is classified
Each channel message gets one of three verdicts.
| Verdict | Meaning | Evidence |
| --- | --- | --- |
| `scoped` | A region code was set | The message matched a configured `flood_scopes` entry, or RF correlated to this message was a `TC_FLOOD` carrying a transport code |
| `global` | No region code | RF correlated to this message was an ordinary `FLOOD` |
| `unknown` | The radio could not tell | Anything else |
Every verdict requires RF the bot has tied to *this* message, by matching the channel payload's type, path length and SNR against the packets it logged. A most-recent-packet guess is not correlation and yields `unknown`.
Only `global` can earn a warning, and only on that positive evidence. Absence of correlation is not proof that a sender omitted a region, and this feature answers that ambiguity by staying quiet.
`flood_scopes` accepts a weaker inference for its own purposes — "no scope-eligible packet was heard in the window, therefore this message was unscoped" — because the cost of being wrong there is one reply the operator broadly wanted. The cost of being wrong here is an unsolicited message accusing someone of a misconfiguration they may not have, so that route is deliberately not used.
`unknown` is excluded from the unscoped percentage rather than counted as clean, so a mesh the bot cannot classify reads as "no data" instead of "no problem".
### Who the sender is
**A channel sender is a display name, not an identity.** MeshCore's `CHANNEL_MSG_RECV` carries no public key: the sender is the `Name: ` prefix of the decrypted text, which anyone holding the channel key can set to anything. Nothing the bot can do authenticates it.
What it does instead is refuse to act on a name with nothing behind it:
- A message with no `Name: ` prefix has no attributable sender at all. It is counted, but can never earn anyone a warning — every such message would otherwise share one synthetic identity.
- DM delivery requires a contact the radio already knows by that name. That does not prove the message came from that node, but it keeps the bot from messaging someone on a stranger's say-so, and it avoids spending cap slots on sends that would fail anyway. Warnings dropped this way are counted and shown on the page, so a bot that keeps no contacts does not silently do nothing.
Contacts come from adverts, so on a live mesh most active nodes are contacts. This closes off targeting an arbitrary made-up name; it does not stop someone impersonating a real neighbor.
- The per-sender cooldown and the daily cap bound how much one forged name can cost — six DMs a day, one per name per week, at the defaults.
If that residual risk matters on your mesh, leave warnings in dry run and read the log rather than sending.
Messages the radio cached from before the current connection are skipped entirely — a reconnect replays them as a burst, and counting them would both distort the tallies and let stale traffic earn someone a warning.
## Reading the page
**Channel traffic by scope** breaks the window down per channel. Each bar is unscoped / scoped / couldn't-tell, and the numbers are printed beside it.
**Daily volume** is the same data per day, plus the headline share.
A channel sitting at a high unscoped percentage is where a warning would do the most good. A channel dominated by "couldn't tell" means your radio is not hearing enough RF detail to judge, and warnings there will rarely fire.
## Turning warnings on
The **What the bot does** control has three positions:
- **Count only** — tallies for the page, nothing transmitted. The default.
- **Dry run** — warnings are decided and logged exactly as they would be sent, including consuming the cooldowns and the daily cap, but nothing is transmitted. The log underneath shows precisely what going live would put on the air.
- **Send warnings** — transmits.
Run it in dry run for a few days first. Because dry run spends the same budget, the log is a true preview rather than an upper bound — with the corollary that a sender the preview already covered stays on their cooldown when you go live, so the first real warnings may lag.
### Delivery
- **Direct message** (default) — the sender alone sees it, and it is the cheaper of the two. Only sent to a name the radio already has a contact for; see [Who the sender is](#who-the-sender-is). If your bot does not keep contacts, use channel delivery instead.
- **Channel reply** — sent at **global** scope on purpose. The recipient is by definition outside any region your bot replies under, so a scoped reply would never reach them. Everyone on the channel sees it.
### Limits
| Setting | Default | What it does |
| --- | --- | --- |
| `min_unscoped_messages` | 3 | Confirmed-unscoped messages from one sender before they earn a warning. The run resets after 24h of quiet, when the sender posts a scoped message, and on restart. |
| `per_sender_cooldown_hours` | 168 | Do not warn the same sender again within this many hours. 0 disables. |
| `mesh_cooldown_minutes` | 30 | Minimum gap between warnings to anyone. 0 disables. |
| `max_warnings_per_day` | 6 | Hard ceiling per local day. 0 means unlimited. |
Banned users are never warned, and the bot skips messages whose sender name matches `[Bot] bot_name`. On this path that is the only check available, so a node that adopts your bot's name exempts itself — see [Who the sender is](#who-the-sender-is). `channelpause` silences warnings along with everything else on channels.
The daily cap and the per-sender cooldown both count **attempts**, failures included: their job is to bound how much unprompted activity this feature can produce, and a send that reported failure may still have put something on the air before it did. They have to agree — when only the cap counted failures, one unreachable node spent the whole day's budget every day and nobody was ever warned.
A failed send does not start the *mesh-wide* cooldown, since usually nothing was transmitted and it should not silence the next sender.
Both cooldowns and the cap read from the database rather than from memory, so restarting the bot does not release a burst of warnings.
### Message
`{sender}` and `{channel}` are substituted. Keep it short: a DM body is 158 UTF-8 bytes and a channel reply is smaller still (160 minus your bot's name), and anything longer is truncated. The page shows the byte count live against whichever limit applies.
For channel delivery, include `@[{sender}]` so the person you are addressing sees it.
## Configuration
Everything the page writes lives in `[Region_Warnings]`:
```ini
[Region_Warnings]
track_traffic = true
enabled = false
dry_run = true
delivery = dm
# channels = general, public
# message = Heads up: your messages have no region code, ...
min_unscoped_messages = 3
per_sender_cooldown_hours = 168
mesh_cooldown_minutes = 30
max_warnings_per_day = 6
```
`channels` is an allowlist; empty means every channel the bot hears. The leading `#` is optional and case does not matter.
Saving from the Web Viewer queues a hot config reload, so changes take effect without a restart.
## Storage and retention
Two small tables:
- `region_scope_daily` — one row per channel per local date holding the three counts.
- `region_warning_events` — one row per warning decision that reached the send stage (sent, failed, or withheld by dry run). Suppressions by cooldown or cap are deliberately not rows; they are the common case and would bury the log.
Both are pruned by `[Data_Retention] region_warning_retention_days` (default 90).
Set `track_traffic = false` to stop writing tallies. Warnings still work; you just lose the evidence the page is built on.
## Relationship to `flood_scopes`
`[Channels] flood_scopes` decides which messages the bot will *reply* to. Region warnings observe every channel message regardless, and the observation runs before that allowlist — an unscoped message is exactly what a scoped allowlist drops, so measuring after the gate would blind the monitor to the traffic it exists to measure.
If you have `flood_scopes` configured with region names and no `*`, unscoped messages get no command replies at all. Region warnings still see them, and can still tell the sender why the bot is ignoring them.
+50
View File
@@ -283,6 +283,16 @@ window; older days stay frozen at the value recorded then.
- *Response Path Hashing*: the path hash size the firmware uses for each hop
when building outgoing/response paths (mode 0–2 = 1–3 bytes per hop; larger
hashes avoid relay collisions but need firmware 1.14+ mesh-wide)
- *Default Region Scope*: the radio's own default region, stored in firmware
(`NodePrefs.default_scope_name` / `default_scope_key`) and used for any send
the bot does not scope itself. Max 30 ASCII characters including the `#`,
which is added if you omit it; blank clears it and the radio floods
unscoped. Firmware without the setting is reported as such rather than
shown as an empty field, and a stored key that is not the stored name's
hash is flagged, because the radio routes by the key. Note that once the
bot sends a scoped message it leaves the radio in forced-unscoped mode, so
this default stops applying until the bot scopes another send — to have the
bot post under a region, use Region Scopes below
- *Identity & Adverts*: node name, advertised latitude/longitude, advert
location policy, and buttons to send a zero-hop or flood advert. The name
field is locked when the bot manages it (`[Bot] bot_name` with
@@ -294,6 +304,30 @@ window; older days stay frozen at the value recorded then.
device itself
- *Advanced Tuning*: RX delay base and airtime factor (write-only; the device
does not report current values)
- **Region Scopes**: the bot's own regional flood scopes, edited here rather
than by hand in `config.ini`. Unlike the cards above it, this writes
`[Channels]` in `config.ini` and queues a hot config reload — nothing is read
from or written to the radio. This is a different setting from the radio's
Default Region Scope above: this one decides what the bot sends and answers,
that one is the radio's fallback for anything the bot leaves unscoped
- *Which scopes the bot replies to*: `flood_scopes`. Either "reply whatever
the scope" (the key is left empty, which is also the default) or an
allowlist of named regions, optionally including unscoped FLOOD via the
"also reply to unscoped messages" box, which is the `*` entry
- *Default outgoing scope*: `outgoing_flood_scope_override`, used for sends
that carry no scope of their own (scheduled messages, feeds, webhooks) and
for replies whose incoming scope could not be matched. Blank means global
flood. A mirrored reply scope still wins over it
- Per-channel `flood_scope.<channel>` entries are listed read-only, because
they override the default on their own channel; edit those in `config.ini`
- Names are normalized the way the bot normalizes them, so `west` is stored
as `#west`. A name containing `,`, `%` or an inner `#` is refused: the
first separates the list, the second breaks every later read of the section
(config.ini is parsed with interpolation on), and the third is the scope
marker itself
- The page reports what the bot did with the edit, not just that the file was
written — it polls the queued reload and says so plainly when nothing
picked it up
- Device writes are queued through the bot process (`channel_operations` table),
so the bot must be running and connected to the radio for reads/writes to
complete
@@ -307,6 +341,22 @@ window; older days stay frozen at the value recorded then.
The viewer also provides JSON API endpoints:
- `GET /api/region-warnings` - Region-code settings, per-channel scope tallies,
the daily series, today's warning budget, and recent warning decisions
- `POST /api/region-warnings/settings` - Save `[Region_Warnings]` and queue a
hot config reload
- `GET /api/region-scopes` - Regional flood scopes as the bot resolves them:
the `[Channels] flood_scopes` allowlist split into named scopes and the
global flag, the default outgoing scope, any per-channel overrides, and which
file a save would write
- `POST /api/radio/firmware/config/write` - Queue a firmware write.
`path_hash_mode` (0-2) and `default_flood_scope` (a region name, or empty to
clear the radio's default) may be sent together or alone.
`GET`-side values come back from `POST /api/radio/firmware/config/read`
- `POST /api/region-scopes` - Save `[Channels] flood_scopes` and
`outgoing_flood_scope_override`, and queue a hot config reload. Returns the
reload's `channel_operations` id so the caller can poll
`/api/channel-operations/<id>` for what the bot actually did
- `GET /api/dashboard/summary` - Snapshot-backed dashboard payload, including
30-day sparkline series and change figures, plus `packet_encoding`: 30 days of
raw per-payload-type multibyte/total counts for the stacked encoding chart.
+1
View File
@@ -61,6 +61,7 @@ nav:
- Check-in API: checkin-api.md
- Path Command: path-command-config.md
- Config validation: config-validation.md
- Region warnings: region-warnings.md
- Web Viewer: web-viewer.md
- Command Reference:
- Overview: command-reference.md
+21 -15
View File
@@ -31,7 +31,16 @@ from .config_validation import (
_channel_name_is_public,
strip_optional_quotes,
)
from .models import CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD, MeshMessage
from .flood_scope import (
is_global_marker,
normalize_scope_name,
)
from .models import (
CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD,
DM_BODY_LIMIT,
MeshMessage,
channel_body_limit,
)
from .plugin_loader import PluginLoader
from .security_utils import sanitize_name, validate_safe_path
from .utils import check_internet_connectivity_async, decode_escape_sequences, format_keyword_response_with_placeholders
@@ -166,7 +175,7 @@ class CommandManager:
return scope_keys
for entry in (s.strip() for s in raw.split(",") if s.strip()):
normalized = self._normalize_scope_name(entry)
if normalized in ("", "*", "0", "None"):
if is_global_marker(normalized):
self.flood_scope_allow_global = True
elif normalized:
scope_keys[normalized] = sha256(normalized.encode()).digest()[:16]
@@ -177,16 +186,10 @@ class CommandManager:
)
return scope_keys
@staticmethod
def _normalize_scope_name(scope: str) -> str:
"""Return scope with '#' prepended if it is a non-global named region without one."""
if scope in ("", "*", "0", "None") or scope.lower() == "none":
if scope.lower() == "none":
return "None"
return scope
if not scope.startswith("#"):
return "#" + scope
return scope
# Canonical implementation lives in modules.flood_scope so the web viewer,
# a separate process, can validate what the operator types without
# importing the bot's command machinery.
_normalize_scope_name = staticmethod(normalize_scope_name)
@staticmethod
def _normalize_channel_name_for_scope_config(channel: str) -> str:
@@ -688,7 +691,7 @@ class CommandManager:
can be called outside of a specific command instance.
"""
if message.is_dm:
return 158
return DM_BODY_LIMIT
username: str | None = None
try:
if hasattr(self.bot, 'meshcore') and self.bot.meshcore:
@@ -702,7 +705,7 @@ class CommandManager:
pass
if not username:
username = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
max_length = max(130, 160 - len(str(username).encode('utf-8')) - 2)
max_length = channel_body_limit(username)
if not MeshMessage.is_global_flood_scope(message.effective_outgoing_flood_scope(self.bot)):
max_length -= CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD
return max_length
@@ -1316,7 +1319,10 @@ class CommandManager:
scope_to_use = (
resolved if resolved is not None else self._outgoing_flood_scope_override()
) or ""
scope_is_global = scope_to_use in ("", "*", "0", "None")
# is_global_marker, not a bare membership test: a hand-written
# "none" normalizes to the global marker everywhere else, so
# treating it as the region "#none" here would send scoped.
scope_is_global = is_global_marker(scope_to_use)
if not scope_is_global:
scope_to_use = self._normalize_scope_name(scope_to_use)
override_cfg = self._outgoing_flood_scope_override()
+8 -5
View File
@@ -19,7 +19,12 @@ from ..command_prefix import (
normalize_command_content,
)
from ..config_schema import LEGACY_ENABLED_ALIASES
from ..models import CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD, MeshMessage
from ..models import (
CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD,
DM_BODY_LIMIT,
MeshMessage,
channel_body_limit,
)
from ..security_utils import validate_pubkey_format
from ..utils import (
format_elapsed_display,
@@ -689,7 +694,7 @@ class BaseCommand(ABC):
int: Maximum message body length in UTF-8 bytes.
"""
if message.is_dm:
return 158
return DM_BODY_LIMIT
# For channel messages, calculate based on bot username length
# Try to get device username from meshcore first (actual radio username)
@@ -712,9 +717,7 @@ class BaseCommand(ABC):
if not username:
username = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
# 160 bytes are available for channel messages
# Calculate max length: 160 - username_length - 2 (for ": ")
max_length = max(130, 160 - len(str(username).encode('utf-8')) - 2)
max_length = channel_body_limit(username)
if not MeshMessage.is_global_flood_scope(message.effective_outgoing_flood_scope(self.bot)):
max_length -= CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD
return max_length
+12
View File
@@ -266,6 +266,18 @@ SECTIONS: dict[str, SectionMeta] = {
"defer_to_human_greeting": KeyMeta(type="bool"),
"levenshtein_distance": KeyMeta(type="int", default="0"),
}),
"Region_Warnings": SectionMeta(keys={
"enabled": KeyMeta(type="bool", default="false"),
"dry_run": KeyMeta(type="bool", default="true"),
"delivery": KeyMeta(default="dm"),
"channels": KeyMeta(),
"message": KeyMeta(),
"min_unscoped_messages": KeyMeta(type="int", default="3"),
"per_sender_cooldown_hours": KeyMeta(type="float", default="168"),
"mesh_cooldown_minutes": KeyMeta(type="float", default="30"),
"max_warnings_per_day": KeyMeta(type="int", default="6"),
"track_traffic": KeyMeta(type="bool", default="true"),
}),
"Announcements_Command": SectionMeta(dynamic_keys=True),
"Alert_Command": SectionMeta(dynamic_keys=True),
}
+1
View File
@@ -71,6 +71,7 @@ CANONICAL_NON_COMMAND_SECTIONS = frozenset({
"DiscordBridge",
"TelegramBridge",
"DARC_MoWaS_Service",
"Region_Warnings",
})
# Sections required for the bot to start (accessed without has_section guards)
+13
View File
@@ -330,6 +330,14 @@ class MeshCoreBot:
self.message_handler = MessageHandler(self)
self.command_manager = CommandManager(self)
# Regional flood-scope tallies, and the opt-in warning they can drive.
try:
from .region_warning import RegionWarningMonitor
self.region_warning_monitor = RegionWarningMonitor(self)
except Exception as e:
self.logger.warning(f"Failed to initialize region warning monitor: {e}")
self.region_warning_monitor = None
# Initialize transmission tracker for monitoring TX success
try:
self.transmission_tracker = TransmissionTracker(self)
@@ -967,6 +975,9 @@ class MeshCoreBot:
self.channel_manager.max_channels = new_max_channels
set_config(new_config)
if getattr(self, 'region_warning_monitor', None):
self.region_warning_monitor.reload_config()
if hasattr(self, 'scheduler'):
scheduler_apply_started = True
self.scheduler.setup_scheduled_messages()
@@ -992,6 +1003,8 @@ class MeshCoreBot:
)
self.channel_manager.max_channels = old_state["max_channels"]
set_config(old_config)
if getattr(self, 'region_warning_monitor', None):
self.region_warning_monitor.reload_config()
# setup_scheduled_messages may have stopped the previous
# APScheduler before failing. Rebuild it against old config.
if scheduler_apply_started and hasattr(self, 'scheduler'):
+2
View File
@@ -61,6 +61,8 @@ class DBManager:
'observed_paths', # Repeater manager - observed paths from adverts and messages
'neighbor_links', # Zero-hop neighbor discovery - current adjacency
'neighbor_observations', # Zero-hop neighbor discovery - per-cycle history
'region_scope_daily', # Regional flood scope tallies per channel per day
'region_warning_events', # Region-code warning decisions
}
def __init__(self, bot: Any, db_path: str = "meshcore_bot.db"):
+48
View File
@@ -800,6 +800,53 @@ def _m0023_observed_paths_zero_hop_signal(cursor: sqlite3.Cursor) -> None:
_add_column(cursor, "observed_paths", "snr", "REAL")
_add_column(cursor, "observed_paths", "rssi", "REAL")
def _m0024_region_scope_tables(cursor: sqlite3.Cursor) -> None:
"""Storage for regional flood-scope observation and the warnings it drives.
``region_scope_daily`` is a per-day, per-channel tally of how each channel
message's flood scope was classified. It is written for every channel
message the bot hears, independent of whether warnings are enabled, because
the whole point is letting an operator see how much unscoped traffic there
actually is *before* deciding to spend airtime telling anyone about it.
One row per channel per local date keeps that free.
``region_warning_events`` holds one row per warning decision that reached
the send stage — actually sent, suppressed by a failed send, or withheld
because the feature is in dry-run. Suppressions by cooldown or daily cap
are deliberately *not* rows: they are the common case and would bury the
log. The cooldown and cap windows are derived from this table rather than
from memory so they survive a restart, which is why dry-run rows are
written too — a dry run has to consume the same budget it is previewing.
"""
cursor.executescript(
"""
CREATE TABLE IF NOT EXISTS region_scope_daily (
date TEXT NOT NULL,
channel TEXT NOT NULL,
scoped_count INTEGER NOT NULL DEFAULT 0,
global_count INTEGER NOT NULL DEFAULT 0,
unknown_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, channel)
);
CREATE TABLE IF NOT EXISTS region_warning_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP NOT NULL,
sender_id TEXT NOT NULL,
sender_pubkey TEXT,
channel TEXT,
delivery TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_region_warning_events_created_at
ON region_warning_events(created_at);
CREATE INDEX IF NOT EXISTS idx_region_warning_events_sender
ON region_warning_events(sender_id, created_at);
"""
)
# ---------------------------------------------------------------------------
# Migration registry — append new entries here, never remove or reorder.
@@ -831,6 +878,7 @@ MIGRATIONS: list[MigrationEntry] = [
(21, "daily_rollup: per-payload-type multibyte split", _m0021_daily_rollup_packet_type_encoding),
(22, "neighbor discovery tables", _m0022_neighbor_tables),
(23, "observed_paths: snr/rssi for zero-hop adverts", _m0023_observed_paths_zero_hop_signal),
(24, "regional flood scope tallies and warning events", _m0024_region_scope_tables),
]
+150
View File
@@ -0,0 +1,150 @@
"""Regional flood scope ("region code") names, shared by the bot and the viewer.
A MeshCore client with a region set sends channel messages as TC_FLOOD, whose
transport code confines them to repeaters holding that region's key. The key is
derived from the scope *name*, so the name is the whole setting: ``west`` and
``#west`` are the same region, and everything hashes the leading-``#`` form.
``[Channels] flood_scopes`` and ``[Channels] outgoing_flood_scope_override``
are edited from the web viewer, which runs in its own process and must not
import the bot's command machinery to check a name the operator typed. So the
normalization the bot has always used, and the rules for what survives a trip
through ``config.ini``, live here and both sides call in.
"""
from __future__ import annotations
from hashlib import sha256
# Scope values that mean "no region" — an ordinary FLOOD every repeater
# rebroadcasts. Empty is how config.ini spells "unset".
GLOBAL_MARKERS = ("", "*", "0", "None")
# A scope name is hashed whole, so there is no protocol limit; this is a sanity
# bound on what the UI will write into a comma-separated INI value.
MAX_SCOPE_NAME_LENGTH = 64
# The companion firmware stores its own default scope in a fixed 31-byte field
# and rejects a name that does not fit with its terminator
# (NodePrefs.default_scope_name, and the n > 0 && n < 31 check in
# CMD_SET_DEFAULT_FLOOD_SCOPE). That is 30 bytes of name, '#' included.
MAX_DEVICE_SCOPE_NAME_LENGTH = 30
# Characters that do not survive the round trip. ',' separates flood_scopes
# entries; '%' is read as an interpolation token by every later configparser
# read of the section; a newline ends the key's line and re-parses the rest as
# INI. '#' is the scope marker itself and only belongs at the front.
_FORBIDDEN_IN_NAME = {",", "%", "#"}
def normalize_scope_name(scope: str) -> str:
"""Return scope with '#' prepended if it is a non-global named region without one."""
if scope in GLOBAL_MARKERS or scope.lower() == "none":
if scope.lower() == "none":
return "None"
return scope
if not scope.startswith("#"):
return "#" + scope
return scope
def is_global_marker(scope: str) -> bool:
"""True when ``scope`` means global flood rather than a named region."""
return scope in GLOBAL_MARKERS or scope.lower() == "none"
def validate_scope_name(scope: str) -> str:
"""Return the canonical form of one scope, or raise ``ValueError``.
Global markers come back canonicalised (``none`` → ``None``); a named
region comes back with its leading ``#``.
"""
raw = str(scope or "").strip()
if is_global_marker(raw):
return normalize_scope_name(raw)
normalized = normalize_scope_name(raw)
body = normalized[1:]
if not body.strip():
raise ValueError("A region scope needs a name after the '#'")
if len(normalized) > MAX_SCOPE_NAME_LENGTH:
raise ValueError(
f"Region scope {normalized!r} is longer than {MAX_SCOPE_NAME_LENGTH} characters"
)
for char in body:
if ord(char) < 32 or ord(char) == 127:
raise ValueError(f"Region scope {raw!r} contains a control character")
if char in _FORBIDDEN_IN_NAME:
raise ValueError(
f"Region scope {raw!r} cannot contain {char!r} — "
"',' separates scopes, '%' breaks config.ini, and '#' only leads"
)
return normalized
def parse_scope_list(raw: str) -> list[str]:
"""Split a comma-separated ``flood_scopes`` value into its raw entries."""
return [part.strip() for part in str(raw or "").split(",") if part.strip()]
def split_allowlist(raw: str) -> tuple[list[str], bool]:
"""Return ``(named_scopes, allow_global)`` for a ``flood_scopes`` value.
Mirrors ``CommandManager._load_flood_scope_keys``: global markers do not
become keys, they just opt unscoped FLOOD through the allowlist. Duplicates
collapse so the UI shows the list the bot actually matches against.
"""
named: list[str] = []
allow_global = False
for entry in parse_scope_list(raw):
normalized = normalize_scope_name(entry)
if is_global_marker(normalized):
allow_global = True
elif normalized not in named:
named.append(normalized)
return named, allow_global
def format_scope_list(scopes: list[str] | tuple[str, ...]) -> str:
"""Join scopes into the comma-separated form ``flood_scopes`` expects."""
return ", ".join(scopes)
def scope_key_hex(scope: str) -> str:
"""The 16-byte transport key a named scope hashes to, as hex.
SHA-256 over the '#'-prefixed name, truncated to 16 bytes — the same
derivation as the firmware's TransportKeyStore::getAutoKeyFor and the
meshcore library, so a key read back from a radio can be compared with the
name stored beside it.
"""
normalized = normalize_scope_name(str(scope or "").strip())
if is_global_marker(normalized):
return ""
return sha256(normalized.encode("utf-8")).hexdigest()[:32]
def validate_device_scope_name(scope: str) -> str:
"""Return the canonical form of a scope the radio can store, or raise.
Stricter than :func:`validate_scope_name` on two counts, both of them the
firmware's and the library's, not ours: the name has to fit the radio's
31-byte field, and it has to be ASCII. The library pads the name frame by
character count while encoding it as UTF-8, so a multi-byte character
pushes the transport key past the field it belongs in and the radio stores
a key that is not the one the name hashes to.
"""
canonical = validate_scope_name(scope)
if is_global_marker(canonical):
return canonical
encoded = canonical.encode("utf-8")
if not canonical.isascii():
raise ValueError(
f"Region scope {canonical!r} must be ASCII to be stored on the radio"
)
if len(encoded) > MAX_DEVICE_SCOPE_NAME_LENGTH:
raise ValueError(
f"Region scope {canonical!r} is longer than "
f"{MAX_DEVICE_SCOPE_NAME_LENGTH} characters, which is all the radio stores"
)
return canonical
+38
View File
@@ -159,6 +159,9 @@ class MaintenanceRunner:
'Data_Retention', 'neighbor_observations_retention_days', 365
)
stats_days = get_retention_days('Stats_Command', 'data_retention_days', 7)
region_warning_days = get_retention_days(
'Data_Retention', 'region_warning_retention_days', 90
)
try:
if hasattr(self.bot, 'web_viewer_integration') and self.bot.web_viewer_integration:
@@ -205,6 +208,8 @@ class MaintenanceRunner:
# pruned here (losing it would silently drop confirmed direct links).
self._cleanup_neighbor_observations(neighbor_observations_days)
self._cleanup_region_scope_history(region_warning_days)
ran_at = _utc_now().isoformat()
self._last_retention_stats['ran_at'] = ran_at
try:
@@ -223,6 +228,39 @@ class MaintenanceRunner:
except Exception:
pass
def _cleanup_region_scope_history(self, retention_days: int) -> None:
"""Prune region-scope tallies and warning events past the retention window.
Both tables are small — one tally row per channel per day, and warning
events are rate-limited by construction — so the window is generous.
"""
if retention_days <= 0:
return
db_manager = getattr(self.bot, 'db_manager', None)
if not db_manager or not hasattr(db_manager, 'delete_timestamp_rows_in_chunks'):
return
# These rows are written in [Bot] timezone, not UTC, so the cutoff has to
# be too or the window is off by the host's offset.
from modules.region_warning import local_now
cutoff_date = (
local_now(getattr(self.bot, 'config', None), self.logger)
- datetime.timedelta(days=retention_days)
).date().isoformat()
try:
db_manager.delete_timestamp_rows_in_chunks(
'region_scope_daily', 'date', cutoff_date,
progress_label='region_scope_daily retention',
)
except Exception as e:
self.logger.warning(f"Region scope tally retention failed: {e}")
try:
db_manager.delete_timestamp_rows_in_chunks(
'region_warning_events', 'created_at', cutoff_date,
progress_label='region_warning_events retention',
)
except Exception as e:
self.logger.warning(f"Region warning event retention failed: {e}")
def _cleanup_neighbor_observations(self, retention_days: int) -> None:
"""Prune zero-hop neighbor observation history past the retention window.
+125 -1
View File
@@ -18,6 +18,7 @@ from .graph_trace_helper import update_mesh_graph_from_trace_data
from .meshcore_payload_decode import channel_hash_for_key, decrypt_group_text
from .models import MeshMessage
from .neighbors_discovery import upsert_zero_hop_observed_path_via_manager
from .region_warning import VERDICT_GLOBAL, VERDICT_SCOPED, VERDICT_UNKNOWN
from .security_utils import sanitize_input, sanitize_name
from .utils import (
calculate_packet_hash,
@@ -30,6 +31,10 @@ from .utils import (
# MessageHandler.find_recent_rf_data. Anything other than a fallback is known to be
# this message's own packet; a fallback is merely the most recent packet heard, so its
# route belongs to some other transmission and must not be attributed (issue #80).
# Stand-in used when a channel message carries no "Name: " prefix to extract a
# sender from. It is not a node: every such message would share this identity.
CHANNEL_SENDER_FALLBACK = "Channel User"
RF_MATCH_KEY = "_rf_match"
RF_MATCH_EXACT = "exact"
RF_MATCH_PUBKEY = "pubkey"
@@ -319,6 +324,107 @@ class MessageHandler:
return False
return int(payload_type) == self._grp_txt_payload_type_int()
def _classify_channel_flood_scope(
self,
*,
reply_scope: str | None,
recent_rf_data: dict[str, Any] | None,
packet_info: dict[str, Any] | None,
scope_rf_data: dict[str, Any] | None,
scope_packet_info: dict[str, Any] | None,
) -> str:
"""Classify a channel message's flood scope as scoped, global, or unknown.
"Scoped" means the message carried a region code (a TC_FLOOD transport
code), whether or not that code matches one of ours. "Global" means it
was proven to be an ordinary unscoped FLOOD. Anything else is unknown.
Every test here needs RF data correlated to *this* message, and the
scoped tests run before the global one, so both kinds of ambiguity
resolve away from ``global``. That direction matters: ``global`` is the
verdict that can spend airtime telling someone to fix their config, and
a message whose scope the radio did not witness is not evidence that the
sender omitted a region.
In particular this does **not** use ``_is_confirmed_global_flood``'s
second route, which infers "unscoped" from the absence of any
scope-eligible packet in the window. That inference is sound enough to
decide whether a ``*`` entry in ``flood_scopes`` authorizes a reply — the
cost of being wrong is one reply the operator broadly wanted — but it is
an argument from absence, and the cost of being wrong here is an
unsolicited message accusing someone of a misconfiguration they may not
have. Channel messages still correlate through
``_find_rf_row_matching_chan_payload`` (payload type, path length and
SNR all agreeing), so the ordinary case is unaffected.
"""
if reply_scope:
return VERDICT_SCOPED
# A correlated scope-eligible row *is* a TC_FLOOD GRP_TXT for this
# message: it carried a transport code, so a region was set even though
# it is not one this bot has keys for.
if (
scope_rf_data
and rf_data_is_correlated(scope_rf_data)
and self._is_rf_data_scope_eligible(scope_rf_data, scope_packet_info)
):
return VERDICT_SCOPED
if recent_rf_data and rf_data_is_correlated(recent_rf_data):
route_type = self._effective_route_type_int(recent_rf_data, packet_info)
if route_type == int(RouteType.TRANSPORT_FLOOD.value):
return VERDICT_SCOPED
if rf_data_is_correlated(recent_rf_data) and self._is_confirmed_global_flood(
recent_rf_data,
packet_info,
scoped_traffic_in_window=scope_rf_data is not None,
):
return VERDICT_GLOBAL
return VERDICT_UNKNOWN
async def _observe_flood_scope(
self,
*,
sender_id: str | None,
sender_pubkey: str | None,
channel: str | None,
sender_timestamp: Any,
reply_scope: str | None,
recent_rf_data: dict[str, Any] | None,
packet_info: dict[str, Any] | None,
scope_rf_data: dict[str, Any] | None,
scope_packet_info: dict[str, Any] | None,
) -> None:
"""Hand this channel message's scope verdict to the region-warning monitor.
Messages the radio cached from before this connection are skipped: on a
reconnect they arrive as a burst of old traffic, and counting them would
both distort the tallies and let a stale message earn someone a warning.
"""
monitor = getattr(self.bot, "region_warning_monitor", None)
if monitor is None:
return
if self._is_old_cached_message(sender_timestamp):
return
try:
verdict = self._classify_channel_flood_scope(
reply_scope=reply_scope,
recent_rf_data=recent_rf_data,
packet_info=packet_info,
scope_rf_data=scope_rf_data,
scope_packet_info=scope_packet_info,
)
await monitor.observe(
verdict=verdict,
sender_id=sender_id,
sender_pubkey=sender_pubkey,
channel=channel,
)
except Exception:
self.logger.exception("Flood scope observation failed")
def _is_old_cached_message(self, timestamp: Any) -> bool:
"""Check if a message timestamp indicates it's from before bot connection.
@@ -2523,7 +2629,7 @@ class MessageHandler:
# Get sender information from text field if it's in "SENDER: message" format
text = payload.get("text", "")
sender_id = "Channel User" # Default fallback
sender_id = CHANNEL_SENDER_FALLBACK # Default fallback
# Try to extract sender from text field (e.g., "HOWL: Test" -> "HOWL")
message_content = text # Default to full text
@@ -2747,6 +2853,24 @@ class MessageHandler:
"cannot authorise a reply under flood_scopes"
)
# Region-code observation happens here, ahead of the flood_scopes
# allowlist below, because an unscoped message is exactly what that
# allowlist drops — running it after the gate would blind the
# monitor to the traffic it exists to measure.
await self._observe_flood_scope(
# A message with no "Name: " prefix has no attributable sender,
# so it is counted but can never earn anyone a warning.
sender_id=None if sender_id == CHANNEL_SENDER_FALLBACK else sender_id,
sender_pubkey=payload.get("pubkey_prefix", ""),
channel=channel_name,
sender_timestamp=payload.get("sender_timestamp", 0),
reply_scope=reply_scope,
recent_rf_data=recent_rf_data,
packet_info=packet_info,
scope_rf_data=scope_rf_data,
scope_packet_info=scope_packet_info,
)
# Allowlist enforcement: when flood_scopes is configured, only reply to
# messages whose scope matched an entry. Unscoped FLOOD is allowed only
# when '*' (or equivalent) is explicitly listed.
+18
View File
@@ -10,6 +10,24 @@ from typing import Any, Optional
# Firmware reserves extra bytes for regional (non-global) TC_FLOOD scope on channel text.
CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD = 10
# A DM carries no username prefix, so the whole cipher block is body.
DM_BODY_LIMIT = 158
def channel_body_limit(username: Optional[str]) -> int:
"""Global-scope body budget in UTF-8 bytes for a channel message from ``username``.
Channel messages go out as ``"<username>: <body>"``, so the budget is the
160-byte cipher block minus the name and the ``": "``. Regional scope costs
a further ``CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD``, which callers
subtract themselves once they know the outgoing scope.
Shared by the command layer and the web viewer, which computes the same
number in a process that has no bot object.
"""
name = str(username or "Bot")
return max(130, 160 - len(name.encode("utf-8")) - 2)
@dataclass
class MeshMessage:
+932
View File
@@ -0,0 +1,932 @@
#!/usr/bin/env python3
"""Regional flood-scope observation and the optional "set a region code" warning.
MeshCore puts a channel message on the air in one of two ways. An ordinary
``FLOOD`` is rebroadcast by every repeater that hears it, anywhere on the mesh.
A ``TC_FLOOD`` carries a transport code derived from a region key — the "region
code" — and only repeaters holding that key pass it on. A client with no region
configured therefore floods the entire mesh with every message it sends, which
is the traffic problem this module is about (issue #279).
Two things live here:
* **Observation.** Every channel message the bot hears is classified as
``scoped``, ``global`` or ``unknown`` and tallied into ``region_scope_daily``.
This costs one upsert per message and no airtime, and it is what lets an
operator see how much unscoped traffic their mesh actually carries *before*
deciding whether telling anyone about it is worth the airtime.
* **Warning.** When explicitly enabled, a sender whose messages are confirmed
unscoped can be sent a short note asking them to set a region code. This
spends airtime automatically, so it is off by default, starts in dry-run, and
is fenced by a per-sender cooldown, a mesh-wide cooldown and a daily cap.
The classification only ever warns on *positive* evidence of a global flood.
Absence of evidence classifies as ``unknown`` and is never warned about — see
``MessageHandler._classify_channel_flood_scope``, which produces the verdict
this module consumes.
The pure ``load_settings`` / summary helpers take a ``configparser`` and a
``DBManager`` rather than the bot, because the web viewer runs in a separate
process and has no bot object.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any, Optional
from modules.models import DM_BODY_LIMIT as _DM_BODY_LIMIT
from modules.models import channel_body_limit
CONFIG_SECTION = "Region_Warnings"
# Verdicts produced by MessageHandler._classify_channel_flood_scope.
VERDICT_SCOPED = "scoped"
VERDICT_GLOBAL = "global"
VERDICT_UNKNOWN = "unknown"
VERDICTS = (VERDICT_SCOPED, VERDICT_GLOBAL, VERDICT_UNKNOWN)
_VERDICT_COLUMN = {
VERDICT_SCOPED: "scoped_count",
VERDICT_GLOBAL: "global_count",
VERDICT_UNKNOWN: "unknown_count",
}
DELIVERY_DM = "dm"
DELIVERY_CHANNEL = "channel"
ACTION_SENT = "sent"
ACTION_DRY_RUN = "dry_run"
ACTION_FAILED = "failed"
# bot_metadata key holding today's count of warnings dropped for want of a
# contact. A counter rather than event rows: this fires on every unscoped
# message from an unknown name, ahead of the cooldowns that would rate-limit
# rows, so logging each one would bury the decisions worth reading.
WITHHELD_METADATA_KEY = "region_warning.withheld_no_contact"
DEFAULT_MESSAGE = (
"Heads up: your messages have no region code, so they flood the whole mesh. "
"Setting one in your MeshCore app keeps things quiet. Thanks!"
)
# Re-exported so the web viewer (which has no bot object) can size the preview
# against the same budget the command layer enforces.
DM_BODY_LIMIT = _DM_BODY_LIMIT
@dataclass(frozen=True)
class RegionWarningSettings:
"""Resolved ``[Region_Warnings]`` configuration."""
enabled: bool = False
dry_run: bool = True
delivery: str = DELIVERY_DM
channels: tuple[str, ...] = ()
message: str = DEFAULT_MESSAGE
min_unscoped_messages: int = 3
per_sender_cooldown_hours: float = 168.0
mesh_cooldown_minutes: float = 30.0
max_warnings_per_day: int = 6
track_traffic: bool = True
def monitors_channel(self, channel: Optional[str]) -> bool:
"""Whether ``channel`` is in the allowlist (an empty allowlist means all)."""
if not self.channels:
return True
return normalize_channel(channel) in self.channels
def normalize_channel(channel: Optional[str]) -> str:
"""Case-fold a channel name and drop a leading ``#`` so config matches the wire."""
return (channel or "").strip().lstrip("#").lower()
def _get(config: Any, key: str, fallback: str = "") -> str:
"""Read one key, raw.
``raw=True`` because configparser's default interpolation raises on a bare
``%`` in a value, and ``message`` is free text an operator types. Without it
a message like "100% of the mesh" would throw here, get swallowed, and the
bot would silently transmit the default wording instead of theirs. The save
endpoint rejects ``%`` outright so a hand-edited config is the only way to
get one, but this still has to read it rather than fall back.
"""
try:
if config is not None and config.has_option(CONFIG_SECTION, key):
return (config.get(CONFIG_SECTION, key, raw=True) or "").strip()
except Exception:
pass
return fallback
def _get_bool(config: Any, key: str, fallback: bool) -> bool:
raw = _get(config, key).lower()
if raw in ("1", "true", "yes", "on"):
return True
if raw in ("0", "false", "no", "off"):
return False
return fallback
def _get_number(config: Any, key: str, fallback: float, *, minimum: float = 0.0) -> float:
"""Parse a numeric setting, falling back rather than raising.
A value below ``minimum`` falls back to the default instead of clamping: on
``max_warnings_per_day`` zero is the "unlimited" sentinel, so clamping a
typo of ``-1`` would quietly remove the daily cap.
"""
raw = _get(config, key)
if not raw:
return fallback
try:
value = float(raw)
except ValueError:
return fallback
if value < minimum:
return fallback
return value
def load_settings(config: Any) -> RegionWarningSettings:
"""Read ``[Region_Warnings]`` into a settings object, falling back to defaults.
Unparseable values fall back rather than raising: this runs on every config
reload in the message path, and a typo should not stop the bot handling mail.
"""
delivery = _get(config, "delivery", DELIVERY_DM).lower()
if delivery not in (DELIVERY_DM, DELIVERY_CHANNEL):
delivery = DELIVERY_DM
channels = tuple(
normalize_channel(part)
for part in _get(config, "channels").split(",")
if normalize_channel(part)
)
message = _get(config, "message") or DEFAULT_MESSAGE
return RegionWarningSettings(
enabled=_get_bool(config, "enabled", False),
dry_run=_get_bool(config, "dry_run", True),
delivery=delivery,
channels=channels,
message=message,
min_unscoped_messages=int(_get_number(config, "min_unscoped_messages", 3, minimum=1)),
per_sender_cooldown_hours=_get_number(config, "per_sender_cooldown_hours", 168.0),
mesh_cooldown_minutes=_get_number(config, "mesh_cooldown_minutes", 30.0),
max_warnings_per_day=int(_get_number(config, "max_warnings_per_day", 6)),
track_traffic=_get_bool(config, "track_traffic", True),
)
def settings_to_config_values(settings: RegionWarningSettings) -> dict[str, str]:
"""Render settings back to INI strings for :mod:`modules.settings_store`."""
def _num(value: float) -> str:
return str(int(value)) if float(value).is_integer() else str(value)
return {
"enabled": "true" if settings.enabled else "false",
"dry_run": "true" if settings.dry_run else "false",
"delivery": settings.delivery,
"channels": ", ".join(settings.channels),
"message": settings.message,
"min_unscoped_messages": str(settings.min_unscoped_messages),
"per_sender_cooldown_hours": _num(settings.per_sender_cooldown_hours),
"mesh_cooldown_minutes": _num(settings.mesh_cooldown_minutes),
"max_warnings_per_day": str(settings.max_warnings_per_day),
"track_traffic": "true" if settings.track_traffic else "false",
}
def render_message(template: str, sender: Optional[str], channel: Optional[str]) -> str:
"""Substitute ``{sender}`` / ``{channel}``, leaving unknown braces untouched."""
text = template or DEFAULT_MESSAGE
replacements = {
"{sender}": sender or "",
"{channel}": channel or "",
}
for token, value in replacements.items():
text = text.replace(token, value)
return text.strip()
def truncate_to_bytes(text: str, limit: int) -> str:
"""Trim ``text`` to ``limit`` UTF-8 bytes without splitting a character."""
encoded = text.encode("utf-8")
if len(encoded) <= limit:
return text
return encoded[:limit].decode("utf-8", errors="ignore")
# ---------------------------------------------------------------------------
# Read helpers — shared with the web viewer, which has no bot object
# ---------------------------------------------------------------------------
def local_now(config: Any, logger: Any = None) -> datetime:
"""Now in the configured ``[Bot] timezone``, as a naive datetime.
Everything this module stores and compares — tallies, the daily cap, both
cooldowns — goes through here, so the whole feature agrees on which day it
is even when ``[Bot] timezone`` differs from the host's. Naive, because the
values are written to SQLite and read back by ``date(created_at)``, which
has no notion of offsets.
"""
try:
from modules.utils import get_config_timezone
tz, _name = get_config_timezone(config, logger)
return datetime.now(tz).replace(tzinfo=None)
except Exception:
return datetime.now()
def _local_today(config: Any, logger: Any = None) -> date:
"""Today's date in the configured ``[Bot] timezone``."""
return local_now(config, logger).date()
def traffic_summary(
db_manager: Any,
config: Any = None,
days: int = 14,
logger: Any = None,
) -> dict[str, Any]:
"""Per-channel scoped/global/unknown tallies over the last ``days`` local days."""
days = max(1, int(days))
since = (_local_today(config, logger) - timedelta(days=days - 1)).isoformat()
try:
# Grouped case-insensitively and without a leading '#': the tally stores
# the channel name as the radio reported it, and a rename between
# "general" and "#General" would otherwise split one channel in two.
rows = db_manager.execute_query(
"SELECT TRIM(MIN(channel)) AS channel, "
"SUM(scoped_count) AS scoped, "
"SUM(global_count) AS global_, "
"SUM(unknown_count) AS unknown "
"FROM region_scope_daily WHERE date >= ? "
"GROUP BY LOWER(LTRIM(TRIM(channel), '#')) "
"ORDER BY (SUM(global_count) + SUM(scoped_count) + SUM(unknown_count)) DESC",
(since,),
)
except Exception:
rows = []
channels: list[dict[str, Any]] = []
totals: dict[str, Any] = {"scoped": 0, "global": 0, "unknown": 0}
for row in rows:
scoped = int(row.get("scoped") or 0)
globally = int(row.get("global_") or 0)
unknown = int(row.get("unknown") or 0)
total = scoped + globally + unknown
if total <= 0:
continue
channels.append({
"channel": row.get("channel") or "",
"scoped": scoped,
"global": globally,
"unknown": unknown,
"total": total,
# Share of *classified* traffic that was unscoped. Unknown messages
# are excluded from the denominator rather than counted as scoped,
# so a mesh the bot cannot classify reads as "no data", not "clean".
"unscoped_pct": round(100.0 * globally / (scoped + globally), 1) if (scoped + globally) else None,
})
totals["scoped"] += scoped
totals["global"] += globally
totals["unknown"] += unknown
classified = totals["scoped"] + totals["global"]
totals["total"] = classified + totals["unknown"]
totals["unscoped_pct"] = round(100.0 * totals["global"] / classified, 1) if classified else None
return {"days": days, "since": since, "channels": channels, "totals": totals}
def daily_series(
db_manager: Any,
config: Any = None,
days: int = 14,
logger: Any = None,
) -> list[dict[str, Any]]:
"""One row per local date over the window, with zero-filled gaps."""
days = max(1, int(days))
today = _local_today(config, logger)
since = (today - timedelta(days=days - 1)).isoformat()
try:
rows = db_manager.execute_query(
"SELECT date, SUM(scoped_count) AS scoped, SUM(global_count) AS global_, "
"SUM(unknown_count) AS unknown FROM region_scope_daily "
"WHERE date >= ? GROUP BY date",
(since,),
)
except Exception:
rows = []
by_date = {
row.get("date"): (
int(row.get("scoped") or 0),
int(row.get("global_") or 0),
int(row.get("unknown") or 0),
)
for row in rows
}
series = []
for offset in range(days - 1, -1, -1):
key = (today - timedelta(days=offset)).isoformat()
scoped, globally, unknown = by_date.get(key, (0, 0, 0))
series.append({
"date": key,
"scoped": scoped,
"global": globally,
"unknown": unknown,
})
return series
def read_withheld(db_manager: Any, today: str) -> int:
"""Today's count of warnings dropped because no contact matched the sender.
Returns 0 for any other date: the counter is a snapshot of one local day,
not a running total.
"""
try:
raw = db_manager.get_metadata(WITHHELD_METADATA_KEY)
if not raw:
return 0
stored = json.loads(raw)
if stored.get("date") != today:
return 0
return max(0, int(stored.get("count") or 0))
except Exception:
return 0
def recent_events(db_manager: Any, limit: int = 50) -> list[dict[str, Any]]:
"""Most recent warning decisions, newest first."""
limit = max(1, min(int(limit), 500))
try:
return db_manager.execute_query(
"SELECT id, created_at, sender_id, sender_pubkey, channel, delivery, action, detail "
"FROM region_warning_events ORDER BY created_at DESC, id DESC LIMIT ?",
(limit,),
)
except Exception:
return []
def warning_budget(
db_manager: Any,
settings: RegionWarningSettings,
config: Any = None,
logger: Any = None,
) -> dict[str, Any]:
"""How much of the daily cap is spent and when the mesh cooldown lifts.
The cap counts *attempts*, failures included. Its job is to bound how much
unprompted activity this feature can produce in a day, and a send that
reported failure may still have put something on the air before it did.
"""
today = _local_today(config, logger).isoformat()
used = 0
delivered = 0
previewed = 0
last_at: Optional[str] = None
try:
rows = db_manager.execute_query(
"SELECT COUNT(*) AS used, "
"SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) AS delivered, "
"SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) AS previewed "
"FROM region_warning_events WHERE date(created_at) = ?",
(ACTION_SENT, ACTION_DRY_RUN, today),
)
if rows:
used = int(rows[0].get("used") or 0)
delivered = int(rows[0].get("delivered") or 0)
previewed = int(rows[0].get("previewed") or 0)
rows = db_manager.execute_query(
"SELECT MAX(created_at) AS last_at FROM region_warning_events WHERE action IN (?, ?)",
(ACTION_SENT, ACTION_DRY_RUN),
)
if rows:
last_at = rows[0].get("last_at")
except Exception:
pass
cap = settings.max_warnings_per_day
return {
"date": today,
# used_today is attempts (what the cap spends). The rest break that down,
# because reporting attempts alone put a count of four beside "last
# warning: none yet", and folding dry runs into "sent" would report a
# morning's preview as afternoon transmissions.
"used_today": used,
"delivered_today": delivered,
"previewed_today": previewed,
"failed_today": max(0, used - delivered - previewed),
"withheld_today": read_withheld(db_manager, today),
"cap": cap,
"remaining": None if cap <= 0 else max(0, cap - used),
"unlimited": cap <= 0,
"last_warning_at": last_at,
}
# ---------------------------------------------------------------------------
# Monitor — bot-side
# ---------------------------------------------------------------------------
@dataclass
class _SenderState:
"""In-memory per-sender counter, rebuilt after a restart."""
unscoped_seen: int = 0
counted_at: float = field(default_factory=time.monotonic)
class RegionWarningMonitor:
"""Tally flood scopes and, when enabled, warn senders who never set one.
**Sender identity is a display name, not an identity.** MeshCore's
``CHANNEL_MSG_RECV`` carries no public key: the sender is the ``"Name: "``
prefix of the decrypted text, which anyone holding the channel key can set
to anything. Nothing here can authenticate it. What it can do is refuse to
act on a name with no node behind it, so DM delivery requires a contact the
radio already knows, and both the per-sender cooldown and the daily cap
bound how much one forged name can cost.
"""
#: How long a sender's unscoped-message run survives without new traffic.
#: A sender who floods three times a year should not accumulate their way
#: to a warning, so the run resets after a quiet spell.
RUN_TTL_SECONDS = 24 * 3600
#: Ceiling on tracked senders. A busy mesh sees thousands of names over a
#: long uptime, and an unbounded dict here would be a slow leak in the
#: channel-message path. Expired runs are swept first; if that is not
#: enough, the least recently seen are dropped, which only delays a
#: warning for a sender who had stopped talking anyway.
MAX_TRACKED_SENDERS = 2000
def __init__(self, bot: Any) -> None:
self.bot = bot
self.logger = bot.logger
self.settings = load_settings(getattr(bot, "config", None))
self._senders: dict[str, _SenderState] = {}
# Written-through from the DB on first use so a restart cannot reset the
# mesh-wide cooldown and let a burst of warnings out.
self._last_warning_monotonic: Optional[float] = None
self._last_warning_wall: Optional[datetime] = None
self._budget_loaded = False
self._withheld_date: str = ""
self._withheld_count = 0
if self.settings.enabled:
self.logger.info(
"Region warnings enabled (%s, delivery=%s, cap=%s/day)",
"dry run" if self.settings.dry_run else "live",
self.settings.delivery,
self.settings.max_warnings_per_day or "unlimited",
)
# -- configuration -----------------------------------------------------
def reload_config(self) -> None:
"""Re-read ``[Region_Warnings]`` after a hot config reload."""
self.settings = load_settings(getattr(self.bot, "config", None))
# -- observation -------------------------------------------------------
async def observe(
self,
*,
verdict: str,
sender_id: Optional[str],
sender_pubkey: Optional[str],
channel: Optional[str],
) -> None:
"""Record one channel message's scope verdict and warn if it earns one.
Never raises: this sits on the channel-message path and a bookkeeping
failure must not cost the message.
"""
try:
if verdict not in _VERDICT_COLUMN:
return
if self.settings.track_traffic:
self._record_verdict(verdict, channel)
if verdict != VERDICT_GLOBAL:
if verdict == VERDICT_SCOPED and sender_id:
# A scoped message proves the sender has a region set now,
# so an earlier unscoped run is stale evidence.
self._senders.pop(sender_id, None)
return
await self._consider_warning(sender_id, sender_pubkey, channel)
except Exception:
self.logger.exception("Region warning monitor failed on a channel message")
def _record_verdict(self, verdict: str, channel: Optional[str]) -> None:
column = _VERDICT_COLUMN[verdict]
today = _local_today(getattr(self.bot, "config", None), self.logger).isoformat()
name = (channel or "").strip() or "(unknown)"
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return
with db_manager.connection() as conn:
# Column name comes from the _VERDICT_COLUMN table, never from input.
conn.execute(
f"INSERT INTO region_scope_daily (date, channel, {column}) VALUES (?, ?, 1) "
f"ON CONFLICT(date, channel) DO UPDATE SET {column} = {column} + 1",
(today, name),
)
conn.commit()
# -- warning decision --------------------------------------------------
async def _consider_warning(
self,
sender_id: Optional[str],
sender_pubkey: Optional[str],
channel: Optional[str],
) -> None:
settings = self.settings
if not settings.enabled or not sender_id:
return
if not settings.monitors_channel(channel):
return
cmd_mgr = getattr(self.bot, "command_manager", None)
if cmd_mgr is None:
return
if cmd_mgr.is_user_banned(sender_id):
return
if self._is_self(sender_id, sender_pubkey):
return
# channelpause silences the bot on channels; a warning is a bot response
# and has no business being the one thing that keeps talking.
if not getattr(self.bot, "channel_responses_enabled", True):
return
if settings.delivery == DELIVERY_DM and not self._is_known_contact(sender_id):
# send_dm would fail anyway, and a failed attempt now spends a cap
# slot and a cooldown. More to the point, the sender is a display
# name off the wire: declining to DM a name with no contact behind
# it keeps the bot from messaging a node on a stranger's say-so.
#
# Counted, not silent: a bot that keeps no contacts drops *every*
# warning here, and without this the page would show an empty log
# forever beside a status card claiming it was sending.
self._record_withheld()
self.logger.debug(
"Region warning for %s withheld: no contact by that name", sender_id
)
return
state = self._senders.get(sender_id)
now = time.monotonic()
if state is None or (now - state.counted_at) > self.RUN_TTL_SECONDS:
state = _SenderState()
self._senders[sender_id] = state
self._evict_stale_senders(now)
state.unscoped_seen += 1
state.counted_at = now
if state.unscoped_seen < settings.min_unscoped_messages:
return
self._load_budget_once()
if self._mesh_cooldown_active(now):
return
if self._sender_cooldown_active(sender_id):
return
if self._daily_cap_reached():
self.logger.debug(
"Region warning for %s withheld: daily cap of %d reached",
sender_id, settings.max_warnings_per_day,
)
return
text = render_message(settings.message, sender_id, channel)
if not text:
return
# The run has been spent whether or not the send itself succeeds; not
# resetting it would retry on the sender's very next message.
state.unscoped_seen = 0
if settings.dry_run:
self._record_event(sender_id, sender_pubkey, channel, ACTION_DRY_RUN, text)
self._mark_warning_sent(now)
self.logger.info(
"Region warning (dry run) for %s on %s: %s", sender_id, channel or "?", text
)
return
# Reserve the slot *before* awaiting the send. Every gate above and this
# reservation run without an await between them, so on the single event
# loop they are atomic: a second channel message arriving mid-send reads
# a cooldown and a cap row that already account for this warning. Doing
# it after the send instead let two concurrent messages both pass a cap
# of one and both transmit.
previous_mark = (self._last_warning_monotonic, self._last_warning_wall)
event_id = self._record_event(
sender_id, sender_pubkey, channel, ACTION_SENT, text)
self._mark_warning_sent(now)
sent, detail = await self._send_warning(sender_id, channel, text)
if sent:
return
# Correct the optimistic reservation. The event row stays — the attempt
# still counts against the daily cap, because a send that reported
# failure may have put something on the air before it did — but a
# failure should not hold the mesh cooldown against the next sender.
self._update_event(event_id, ACTION_FAILED, detail)
self._last_warning_monotonic, self._last_warning_wall = previous_mark
async def _send_warning(
self, sender_id: str, channel: Optional[str], text: str
) -> tuple[bool, str]:
cmd_mgr = self.bot.command_manager
if self.settings.delivery == DELIVERY_CHANNEL:
if not channel:
return False, "no channel to reply on"
body = truncate_to_bytes(text, self._channel_body_limit())
# Deliberately global scope: the recipient is by definition not
# inside any region the bot replies under, so a scoped reply would
# never reach them.
ok = await cmd_mgr.send_channel_message(
channel, body, skip_user_rate_limit=True, scope="*"
)
return ok, body if ok else f"channel send failed: {body}"
body = truncate_to_bytes(text, DM_BODY_LIMIT)
ok = await cmd_mgr.send_dm(sender_id, body, skip_user_rate_limit=True)
return ok, body if ok else f"DM send failed (contact unknown or radio busy): {body}"
def _channel_body_limit(self) -> int:
"""Channel body budget for a global-scope send from this node.
Warnings always go out unscoped, so no regional-scope overhead applies.
"""
try:
from modules.models import MeshMessage
probe = MeshMessage(content="", channel="", is_dm=False, reply_scope="")
return int(self.bot.command_manager.get_max_message_length(probe))
except Exception:
return channel_body_limit(None)
# -- gating ------------------------------------------------------------
def _evict_stale_senders(self, now: float) -> None:
"""Keep ``_senders`` bounded (see ``MAX_TRACKED_SENDERS``)."""
if len(self._senders) <= self.MAX_TRACKED_SENDERS:
return
for name, state in list(self._senders.items()):
if (now - state.counted_at) > self.RUN_TTL_SECONDS:
del self._senders[name]
overflow = len(self._senders) - self.MAX_TRACKED_SENDERS
if overflow <= 0:
return
oldest = sorted(self._senders.items(), key=lambda kv: kv[1].counted_at)
for name, _state in oldest[:overflow]:
del self._senders[name]
def _now(self) -> datetime:
return local_now(getattr(self.bot, "config", None), self.logger)
def _is_self(self, sender_id: str, sender_pubkey: Optional[str]) -> bool:
"""Whether this message came from the bot's own node.
The public key is checked first where one is available, but MeshCore's
CHANNEL_MSG_RECV carries none — see the class docstring — so on the path
this monitor actually runs on, the display name is the only signal and
the fallback below is what decides.
"""
own_key = self._own_public_key()
prefix = (sender_pubkey or "").strip().lower()
if own_key and prefix:
return own_key.startswith(prefix) or prefix.startswith(own_key)
try:
bot_name = self.bot.config.get("Bot", "bot_name", fallback="") or ""
except Exception:
bot_name = ""
return bool(bot_name) and sender_id.strip().lower() == bot_name.strip().lower()
def _record_withheld(self) -> None:
"""Bump today's withheld-for-no-contact counter in ``bot_metadata``.
Keyed by local date so it resets on its own and needs no retention.
"""
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return
today = self._now().date().isoformat()
try:
if self._withheld_date != today:
stored = read_withheld(db_manager, today)
self._withheld_date = today
self._withheld_count = stored
self._withheld_count += 1
db_manager.set_metadata(
WITHHELD_METADATA_KEY,
json.dumps({"date": today, "count": self._withheld_count}),
)
except Exception:
self.logger.debug("Could not record withheld region warning", exc_info=True)
def _is_known_contact(self, sender_id: str) -> bool:
"""Whether a contact by this name is on the radio.
The bot has no way to authenticate a channel sender, so this does not
prove the message came from that node. It does keep a warning pointed at
a node the bot already knows, rather than at any name someone types.
"""
meshcore = getattr(self.bot, "meshcore", None)
if meshcore is None:
return False
lookup = getattr(meshcore, "get_contact_by_name", None)
if callable(lookup):
try:
if lookup(sender_id):
return True
except Exception:
pass
contacts = getattr(meshcore, "contacts", None)
if isinstance(contacts, dict):
wanted = sender_id.strip().lower()
for contact in contacts.values():
if not isinstance(contact, dict):
continue
name = (contact.get("adv_name") or contact.get("name") or "").strip().lower()
if name and name == wanted:
return True
return False
def _own_public_key(self) -> str:
"""This node's public key as lowercase hex, or "" when unavailable."""
try:
self_info = getattr(getattr(self.bot, "meshcore", None), "self_info", None)
if self_info is None:
return ""
if isinstance(self_info, dict):
key = self_info.get("public_key", "")
else:
key = getattr(self_info, "public_key", "")
if isinstance(key, (bytes, bytearray)):
return bytes(key).hex()
return str(key or "").strip().lower()
except Exception:
return ""
def _load_budget_once(self) -> None:
"""Seed the mesh cooldown from the DB so a restart cannot bypass it."""
if self._budget_loaded:
return
self._budget_loaded = True
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return
try:
rows = db_manager.execute_query(
"SELECT MAX(created_at) AS last_at FROM region_warning_events "
"WHERE action IN (?, ?)",
(ACTION_SENT, ACTION_DRY_RUN),
)
except Exception:
return
raw = rows[0].get("last_at") if rows else None
if not raw:
return
parsed = _parse_timestamp(raw)
if parsed is None:
return
self._last_warning_wall = parsed
elapsed = (self._now() - parsed).total_seconds()
if elapsed >= 0:
self._last_warning_monotonic = time.monotonic() - elapsed
def _mark_warning_sent(self, now: float) -> None:
self._last_warning_monotonic = now
self._last_warning_wall = self._now()
def _mesh_cooldown_active(self, now: float) -> bool:
window = self.settings.mesh_cooldown_minutes * 60.0
if window <= 0 or self._last_warning_monotonic is None:
return False
return (now - self._last_warning_monotonic) < window
def _sender_cooldown_active(self, sender_id: str) -> bool:
"""Whether this sender was warned, or attempted, inside the cooldown.
Attempts count, matching the daily cap. Counting only successes meant a
sender the radio can never reach — an unknown contact, which is exactly
what a brand-new client with no region code often is — spent a cap slot
every ``min_unscoped_messages`` messages forever, starving the feature
while nobody was ever warned.
"""
hours = self.settings.per_sender_cooldown_hours
if hours <= 0:
return False
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return False
try:
rows = db_manager.execute_query(
"SELECT MAX(created_at) AS last_at FROM region_warning_events "
"WHERE sender_id = ?",
(sender_id,),
)
except Exception:
return False
raw = rows[0].get("last_at") if rows else None
parsed = _parse_timestamp(raw) if raw else None
if parsed is None:
return False
return (self._now() - parsed) < timedelta(hours=hours)
def _daily_cap_reached(self) -> bool:
cap = self.settings.max_warnings_per_day
if cap <= 0:
return False
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return False
today = self._now().date().isoformat()
try:
# Attempts, not successes: see warning_budget.
rows = db_manager.execute_query(
"SELECT COUNT(*) AS used FROM region_warning_events "
"WHERE date(created_at) = ?",
(today,),
)
except Exception:
return False
used = int(rows[0].get("used") or 0) if rows else 0
return used >= cap
# -- persistence -------------------------------------------------------
def _record_event(
self,
sender_id: str,
sender_pubkey: Optional[str],
channel: Optional[str],
action: str,
detail: str,
) -> Optional[int]:
"""Write one decision row; returns its id so the outcome can be corrected."""
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager:
return None
try:
with db_manager.connection() as conn:
cursor = conn.execute(
"INSERT INTO region_warning_events "
"(created_at, sender_id, sender_pubkey, channel, delivery, action, detail) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self._now().isoformat(sep=" ", timespec="seconds"),
sender_id,
(sender_pubkey or "")[:64] or None,
channel,
self.settings.delivery,
action,
detail[:400] if detail else None,
),
)
conn.commit()
return int(cursor.lastrowid) if cursor.lastrowid else None
except Exception:
self.logger.exception("Failed to record region warning event")
return None
def _update_event(self, event_id: Optional[int], action: str, detail: str) -> None:
"""Replace a reserved row's outcome once the send has resolved."""
db_manager = getattr(self.bot, "db_manager", None)
if not db_manager or event_id is None:
return
try:
with db_manager.connection() as conn:
conn.execute(
"UPDATE region_warning_events SET action = ?, detail = ? WHERE id = ?",
(action, detail[:400] if detail else None, event_id),
)
conn.commit()
except Exception:
self.logger.exception("Failed to update region warning event")
def _parse_timestamp(raw: Any) -> Optional[datetime]:
"""Parse a stored event timestamp, tolerating ``T`` or space separators."""
if not raw:
return None
text = str(raw).strip().replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
+96 -2
View File
@@ -22,6 +22,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.date import DateTrigger
from meshcore.events import EventType
from .flood_scope import scope_key_hex
from .maintenance import MaintenanceRunner
from .models import CHANNEL_REGIONAL_FLOOD_SCOPE_BODY_OVERHEAD
from .scheduled_message_cron import (
@@ -1485,7 +1486,7 @@ class MessageScheduler:
self.logger.exception(f"Error in _process_config_operations: {e}")
async def _firmware_read_op(self):
"""Read the path hash mode from radio firmware (device query)."""
"""Read the path hash mode and default flood scope from radio firmware."""
import asyncio
try:
meshcore = getattr(self.bot, 'meshcore', None)
@@ -1496,11 +1497,60 @@ class MessageScheduler:
meshcore.commands.get_path_hash_mode(), timeout=10
)
return True, {'path_hash_mode': path_hash_mode}
result: dict[str, Any] = {'path_hash_mode': path_hash_mode}
result.update(await self._read_default_flood_scope(meshcore))
return True, result
except Exception as e:
self.logger.error(f"Firmware read failed: {e}")
return False, {'error': str(e)}
async def _read_default_flood_scope(self, meshcore) -> dict[str, Any]:
"""Read the radio's own default flood scope, if it has one to report.
Never raises: firmware without CMD_GET_DEFAULT_FLOOD_SCOPE answers with
an error or not at all, and that must not cost the caller the path hash
mode it asked for in the same read. The absence of the keys is how the
page knows the radio did not answer, which is not the same as the radio
answering "no scope set".
"""
getter = getattr(meshcore.commands, 'get_default_flood_scope', None)
if getter is None:
self.logger.debug(
"meshcore library has no get_default_flood_scope; skipping"
)
return {}
try:
event = await asyncio.wait_for(getter(), timeout=10)
except Exception as e: # noqa: BLE001 - optional read, never fatal
self.logger.info("Default flood scope read unavailable: %s", e)
return {}
if event is None or getattr(event, 'type', None) == EventType.ERROR:
self.logger.info(
"Radio did not report a default flood scope (result=%s)", event
)
return {}
payload = getattr(event, 'payload', None) or {}
name = payload.get('scope_name', '') or ''
key = payload.get('scope_key', '') or ''
# A radio with no default scope sends the one-byte sentinel, which the
# library turns into an empty payload. Report that as the cleared
# state, distinct from the radio never having answered.
result: dict[str, Any] = {
'default_scope_name': name,
'default_scope_key': key,
'default_scope_supported': True,
}
if name and key:
# The name is a label stored beside the key; the radio routes by
# the key. A build-flag default stores the name without its '#'
# while hashing the '#' form, so compare on the normalized name.
result['default_scope_key_matches'] = (
scope_key_hex(name).lower() == key.lower()
)
return result
async def _firmware_write_op(self, payload: dict):
"""Write the path hash mode to radio firmware."""
import asyncio
@@ -1524,6 +1574,13 @@ class MessageScheduler:
if not ok:
errors.append(f"set_path_hash_mode({mode}) failed: {result}")
if 'default_flood_scope' in payload:
scope = payload['default_flood_scope']
ok, error = await self._write_default_flood_scope(meshcore, scope)
results['default_flood_scope'] = ok
if error:
errors.append(error)
success = len(errors) == 0
response: dict[str, Any] = {'results': results}
if errors:
@@ -1533,6 +1590,43 @@ class MessageScheduler:
self.logger.error(f"Firmware write failed: {e}")
return False, {'error': str(e)}
async def _write_default_flood_scope(self, meshcore, scope) -> tuple[bool, str]:
"""Set or clear the radio's default flood scope.
Clearing does not go through the library. ``set_default_flood_scope``
cannot do it: ``None`` raises on ``len(None)``, ``""`` makes the
firmware answer ILLEGAL_ARG, and ``"*"`` only works because the frame
is padded by character count rather than by the name it wrote, leaving
it one byte short of the length the firmware reads as "a scope
follows". The firmware's own contract for clearing is a bare
CMD_SET_DEFAULT_FLOOD_SCOPE with nothing after it, so send that.
"""
from meshcore.packets import CommandType
setter = getattr(meshcore.commands, 'set_default_flood_scope', None)
if setter is None:
return False, (
'This meshcore library version cannot set the default flood scope'
)
if scope:
result = await asyncio.wait_for(setter(scope), timeout=10)
label = f'set_default_flood_scope({scope})'
else:
result = await asyncio.wait_for(
meshcore.commands.send(
bytearray([CommandType.SET_DEFAULT_FLOOD_SCOPE.value]),
[EventType.OK, EventType.ERROR],
),
timeout=10,
)
label = 'clear default_flood_scope'
ok = getattr(result, 'type', None) == EventType.OK
if ok:
return True, ''
return False, f'{label} failed: {result}'
async def _radio_params_read_op(self):
"""Read current radio and node parameters via SELF_INFO (appstart)."""
try:
+387 -9
View File
@@ -44,6 +44,7 @@ from flask import (
)
from flask_socketio import SocketIO, disconnect, emit
from modules import flood_scope, region_warning
from modules.database_restore import (
DEFAULT_MAX_RESTORE_BYTES,
DatabaseRestoreError,
@@ -55,6 +56,7 @@ from modules.db_retention import (
)
from modules.ini_writer import IniValueError, update_ini_values
from modules.maintenance import MaintenanceRunner
from modules.models import channel_body_limit
from modules.scheduled_message_admin import (
SECTION as SCHEDULED_MESSAGES_SECTION,
)
@@ -1281,6 +1283,7 @@ class BotDataViewer:
'contacts',
'plugins_page',
'greeter',
'region_warnings_page',
'logs',
'multibyte_rollout',
'mesh',
@@ -1403,6 +1406,11 @@ class BotDataViewer:
"""Greeter management page"""
return render_template('greeter.html')
@self.app.route('/region-warnings')
def region_warnings_page():
"""Regional flood scope monitoring and warning settings."""
return render_template('region_warnings.html')
@self.app.route('/feeds')
def feeds():
"""Feed management page"""
@@ -4041,25 +4049,378 @@ class BotDataViewer:
if conn:
conn.close()
# ── Region warnings (regional flood scope) ───────────────────────────
def _region_warning_channel_limit() -> int:
"""Channel body budget for a global-scope send.
The device's own name is authoritative for the command layer, but
the viewer is a separate process with no radio, so it falls back to
the configured one. They match on any install where the bot manages
the device name.
"""
name = (self.config.get('Bot', 'bot_name', fallback='Bot') or 'Bot').strip()
return channel_body_limit(name or 'Bot')
@self.app.route('/api/region-warnings')
def api_region_warnings():
"""Settings, traffic tallies, budget and recent decisions for the page."""
try:
# Re-read from disk so the page reflects edits made elsewhere.
self.config = self._load_merged_config()
settings = region_warning.load_settings(self.config)
try:
days = max(1, min(int(request.args.get('days', 14)), 90))
except (TypeError, ValueError):
days = 14
known_channels = []
try:
known_channels = [
c.get('name') for c in self._get_channels() if c.get('name')
]
except Exception:
pass
return jsonify({
'settings': region_warning.settings_to_config_values(settings),
'defaults': region_warning.settings_to_config_values(
region_warning.RegionWarningSettings()
),
'default_message': region_warning.DEFAULT_MESSAGE,
'traffic': region_warning.traffic_summary(
self.db_manager, self.config, days, self.logger
),
'series': region_warning.daily_series(
self.db_manager, self.config, days, self.logger
),
'budget': region_warning.warning_budget(
self.db_manager, settings, self.config, self.logger
),
'events': region_warning.recent_events(self.db_manager, 50),
'limits': {
'dm': region_warning.DM_BODY_LIMIT,
'channel': _region_warning_channel_limit(),
},
'known_channels': known_channels,
})
except Exception:
self.logger.exception("Error building region warning view")
return jsonify({'error': 'Internal error — see server logs'}), 500
@self.app.route('/api/region-warnings/settings', methods=['POST'])
def api_region_warnings_save():
"""Persist [Region_Warnings] and queue a hot config reload."""
data = request.get_json(silent=True) or {}
def _as_bool(key, default):
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ('1', 'true', 'yes', 'on')
def _as_number(key, default, minimum=0.0, maximum=None):
raw = data.get(key, default)
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError(f'{key} must be a number')
if value < minimum:
raise ValueError(f'{key} must be at least {minimum:g}')
if maximum is not None and value > maximum:
raise ValueError(f'{key} must be at most {maximum:g}')
return value
try:
delivery = str(data.get('delivery', 'dm')).strip().lower()
if delivery not in (region_warning.DELIVERY_DM, region_warning.DELIVERY_CHANNEL):
raise ValueError('delivery must be "dm" or "channel"')
message = str(data.get('message') or '').strip() or region_warning.DEFAULT_MESSAGE
if '\n' in message or '\r' in message:
raise ValueError('message must be a single line')
if '%' in message:
# config.ini is read with configparser's interpolation on, so a
# bare % raises on every later read of the section — including
# the bot's own config validation, which would then reject every
# hot reload until someone hand-edited the file.
raise ValueError('message cannot contain "%"; write "percent" instead')
if len(message) > 500:
# Far above the 158-byte send budget, but this lands in
# config.ini and in every timestamped backup of it.
raise ValueError('message must be 500 characters or fewer')
channels = data.get('channels')
if isinstance(channels, list):
channel_parts = channels
else:
channel_parts = str(channels or '').split(',')
normalized_channels = []
for part in channel_parts:
name = region_warning.normalize_channel(part)
if name and name not in normalized_channels:
normalized_channels.append(name)
settings = region_warning.RegionWarningSettings(
enabled=_as_bool('enabled', False),
dry_run=_as_bool('dry_run', True),
delivery=delivery,
channels=tuple(normalized_channels),
message=message,
min_unscoped_messages=int(_as_number('min_unscoped_messages', 3, 1, 100)),
per_sender_cooldown_hours=_as_number('per_sender_cooldown_hours', 168, 0, 8760),
mesh_cooldown_minutes=_as_number('mesh_cooldown_minutes', 30, 0, 10080),
max_warnings_per_day=int(_as_number('max_warnings_per_day', 6, 0, 1000)),
track_traffic=_as_bool('track_traffic', True),
)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
section = region_warning.CONFIG_SECTION
target_path = (
self.local_config_path
if section in self._local_sections
else self.config_path
)
try:
store = get_settings_store(self.config, target_path, self.db_manager)
result = store.write_values(
section, region_warning.settings_to_config_values(settings)
)
except IniValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception:
self.logger.exception("Error saving region warning settings")
return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500
backup_path = result.get('backup_path', '') if isinstance(result, dict) else ''
reload_queued = _queue_config_reload()
self.logger.info(
"Region warning settings saved (enabled=%s, dry_run=%s, delivery=%s)",
settings.enabled, settings.dry_run, settings.delivery,
)
return jsonify({
'success': True,
'backup_path': backup_path,
'reload_queued': reload_queued,
'settings': region_warning.settings_to_config_values(settings),
})
# ── Region scopes ([Channels] flood_scopes) ──────────────────────────
def _region_scope_target_path():
"""Where a [Channels] write lands: the local overlay wins if it has
the section, because that is the copy the merged config reads last."""
return (
self.local_config_path
if 'Channels' in self._local_sections
else self.config_path
)
def _region_scope_view():
"""Effective region-scope settings, read the way the bot reads them."""
# [Channels] is canonical; [Bot] is still honoured with a warning by
# CommandManager, so read it the same way or the page would show
# "replies to every scope" while the bot enforces an allowlist.
raw = ''
legacy_section = None
for section in ('Channels', 'Bot'):
if self.config.has_section(section) and self.config.has_option(
section, 'flood_scopes'
):
candidate = (self.config.get(section, 'flood_scopes') or '').strip()
if not candidate:
continue
raw = candidate
if section != 'Channels':
legacy_section = section
break
scopes, allow_global = flood_scope.split_allowlist(raw)
override_raw = ''
if self.config.has_section('Channels') and self.config.has_option(
'Channels', 'outgoing_flood_scope_override'
):
override_raw = (
self.config.get('Channels', 'outgoing_flood_scope_override') or ''
).strip()
override = (
'' if flood_scope.is_global_marker(override_raw)
else flood_scope.normalize_scope_name(override_raw)
)
# Read-only, but it is the answer to "why does that channel ignore
# the default?", so the page shows it rather than making the
# operator open config.ini to find out.
channel_overrides = []
if self.config.has_section('Channels'):
for key, value in self.config.items('Channels'):
if not key.startswith('flood_scope.') or len(key) <= len('flood_scope.'):
continue
configured = (value or '').strip()
channel_overrides.append({
'channel': key[len('flood_scope.'):],
'scope': (
'' if flood_scope.is_global_marker(configured)
else flood_scope.normalize_scope_name(configured)
),
})
channel_overrides.sort(key=lambda entry: entry['channel'].lower())
target = _region_scope_target_path()
if target == self.local_config_path:
target_label = os.path.join(
os.path.basename(os.path.dirname(target)), os.path.basename(target)
)
else:
target_label = os.path.basename(target)
return {
'allowlist_active': bool(scopes or allow_global),
'scopes': scopes,
'allow_global': allow_global,
'outgoing_override': override,
'channel_overrides': channel_overrides,
'legacy_section': legacy_section,
'target': target_label,
'max_name_length': flood_scope.MAX_SCOPE_NAME_LENGTH,
}
@self.app.route('/api/region-scopes')
def api_region_scopes_get():
"""Regional flood scopes from [Channels], as the bot resolves them."""
try:
# Re-read from disk so the page reflects edits made elsewhere,
# and so the local-overlay target is resolved against what is
# on disk now rather than at viewer startup.
self.config = self._load_merged_config()
return jsonify(_region_scope_view())
except Exception:
self.logger.exception("Error reading region scopes")
return jsonify({'error': 'Internal error — see server logs'}), 500
@self.app.route('/api/region-scopes', methods=['POST'])
def api_region_scopes_save():
"""Persist [Channels] flood_scopes / outgoing_flood_scope_override."""
data = request.get_json(silent=True) or {}
def _as_bool(key, default=False):
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ('1', 'true', 'yes', 'on')
try:
submitted = data.get('scopes')
if isinstance(submitted, (list, tuple)):
entries = [str(part).strip() for part in submitted if str(part).strip()]
else:
entries = flood_scope.parse_scope_list(submitted)
allow_global = _as_bool('allow_global', False)
scopes: list[str] = []
for entry in entries:
canonical = flood_scope.validate_scope_name(entry)
# '*' typed into the list box means the same thing as the
# checkbox; fold it in rather than writing it twice.
if flood_scope.is_global_marker(canonical):
allow_global = True
elif canonical not in scopes:
scopes.append(canonical)
allowlist_enabled = _as_bool('allowlist_enabled', bool(scopes or allow_global))
if allowlist_enabled and not scopes and not allow_global:
raise ValueError(
'Add at least one region scope, or turn the allowlist off '
'so the bot replies whatever the scope'
)
# '*' last, matching the order config.ini.example documents.
flood_scopes_value = (
flood_scope.format_scope_list(scopes + (['*'] if allow_global else []))
if allowlist_enabled else ''
)
override_raw = str(data.get('outgoing_override') or '').strip()
# Every global marker means the same send path, but only the
# empty value keeps send_channel_message from logging "override
# was not applied" on each global send. Store the quiet one.
override_value = (
'' if flood_scope.is_global_marker(override_raw)
else flood_scope.validate_scope_name(override_raw)
)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
try:
# Resolve the write target against the config on disk now: the
# local overlay may have grown a [Channels] section since the
# viewer started, and it would silently win over a base write.
self.config = self._load_merged_config()
store = get_settings_store(
self.config, _region_scope_target_path(), self.db_manager
)
result = store.write_values('Channels', {
'flood_scopes': flood_scopes_value,
'outgoing_flood_scope_override': override_value,
})
except IniValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except OSError:
self.logger.exception("Error writing region scopes")
return jsonify({
'success': False,
'error': 'Could not write config.ini — check file permissions',
}), 500
except Exception:
self.logger.exception("Error saving region scopes")
return jsonify({'success': False, 'error': 'Internal error — see server logs'}), 500
backup_path = result.get('backup_path', '') if isinstance(result, dict) else ''
reload_op_id = _queue_config_reload_id()
self.logger.info(
"Region scopes saved: flood_scopes=%r outgoing_flood_scope_override=%r",
flood_scopes_value, override_value,
)
return jsonify({
'success': True,
'backup_path': backup_path,
'reload_queued': reload_op_id is not None,
'reload_operation_id': reload_op_id,
'settings': _region_scope_view(),
})
# Feed management API endpoints
def _schedule_tz():
from modules.utils import get_config_timezone
tz, _name = get_config_timezone(self.config, self.logger)
return tz
def _queue_config_reload():
"""Ask the bot to re-read config.ini; it re-registers scheduled jobs."""
def _queue_config_reload_id():
"""Queue a config reload and return its operation id, or None.
The id lets a caller poll /api/channel-operations/<id> and report
what the bot actually did with the edit, instead of claiming
success because a row was inserted.
"""
try:
with self.db_manager.connection() as conn:
conn.cursor().execute(
cursor = conn.cursor()
cursor.execute(
"INSERT INTO channel_operations (operation_type, status) "
"VALUES ('config_reload', 'pending')"
)
conn.commit()
return True
return cursor.lastrowid
except Exception:
self.logger.exception("Failed to queue config reload")
return False
return None
def _queue_config_reload():
"""Ask the bot to re-read config.ini; it re-registers scheduled jobs."""
return _queue_config_reload_id() is not None
# The duplicate check and the write have to be one critical section, or two
# concurrent creates for the same schedule both pass the check and the second
@@ -4708,19 +5069,36 @@ class BotDataViewer:
@self.app.route('/api/radio/firmware/config/write', methods=['POST'])
def api_firmware_config_write():
"""Queue a firmware config write. Body: {path_hash_mode: int}.
Poll /api/channel-operations/<id> for result."""
"""Queue a firmware config write. Body may carry ``path_hash_mode``
and/or ``default_flood_scope`` (a region name, or empty/null to
clear the radio's default). Poll /api/channel-operations/<id>."""
try:
data = request.get_json(silent=True) or {}
allowed = {'path_hash_mode'}
allowed = {'path_hash_mode', 'default_flood_scope'}
payload = {k: v for k, v in data.items() if k in allowed}
if not payload:
return jsonify({'error': 'No valid fields provided (path_hash_mode)'}), 400
return jsonify({
'error': 'No valid fields provided '
'(path_hash_mode, default_flood_scope)'
}), 400
if 'path_hash_mode' in payload:
mode = int(payload['path_hash_mode'])
if not (0 <= mode <= 2):
return jsonify({'error': 'path_hash_mode must be 0-2 (bytes per hop = mode + 1)'}), 400
payload['path_hash_mode'] = mode
if 'default_flood_scope' in payload:
raw = str(payload['default_flood_scope'] or '').strip()
try:
# A global marker means "no default scope", which the
# radio spells as a cleared field, so both arrive here
# as the empty string.
canonical = (
'' if flood_scope.is_global_marker(raw)
else flood_scope.validate_device_scope_name(raw)
)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
payload['default_flood_scope'] = canonical
with self.db_manager.connection() as conn:
cursor = conn.cursor()
cursor.execute(
+19 -3
View File
@@ -156,12 +156,17 @@
color: var(--text-color);
}
/* Bootstrap's striping sets a light --bs-table-striped-color on the
cell as well as a background. Overriding only the background left
#212529 text on a #3d3d3d row in dark mode — about 1.3:1. */
[data-theme="dark"] .table-striped>tbody>tr:nth-of-type(odd)>td {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="dark"] .table-hover>tbody>tr:hover>td {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
.badge {
@@ -255,8 +260,14 @@
color: var(--text-muted) !important;
}
/* Dark mode borders */
[data-theme="dark"] .border {
/* Dark mode borders. The directional utilities need naming too — the
all-sides rule does not cover them, so a .border-start divider drew
Bootstrap's light #dee2e6 straight onto a dark card. */
[data-theme="dark"] .border,
[data-theme="dark"] .border-top,
[data-theme="dark"] .border-end,
[data-theme="dark"] .border-bottom,
[data-theme="dark"] .border-start {
border-color: var(--border-color) !important;
}
@@ -441,7 +452,7 @@
{#- Configuration pages live behind one gear so the bar stays about
what the mesh is doing, not how the bot is set up. -#}
{% set settings_paths = ['/radio', '/schedule', '/greeter', '/feeds', '/plugins', '/config'] %}
{% set settings_paths = ['/radio', '/schedule', '/greeter', '/feeds', '/region-warnings', '/plugins', '/config'] %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.path in settings_paths }}"
href="#" id="settingsDropdown" role="button"
@@ -473,6 +484,11 @@
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item {{ 'active' if request.path == '/region-warnings' }}" href="/region-warnings">
<i class="fas fa-globe fa-fw me-2"></i>Region Warnings
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.path == '/plugins' }}" href="/plugins">
<i class="fas fa-puzzle-piece fa-fw me-2"></i>Plugins
+413
View File
@@ -173,6 +173,44 @@
<hr class="my-4">
<!-- Default Region Scope (device) -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Default Region Scope
</h6>
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label" for="nodeDefaultScope">Region</label>
<input type="text" class="form-control" id="nodeDefaultScope"
maxlength="30" placeholder="blank = unscoped" autocomplete="off">
<small class="form-text text-muted" id="nodeDefaultScopeKey"></small>
</div>
<div class="col-md-auto">
<button type="button" class="btn btn-warning" id="saveDefaultScopeBtn">
<span class="spinner-border spinner-border-sm me-1" id="saveDefaultScopeSpinner"
style="display:none" role="status"></span>
<i class="fas fa-upload me-1"></i>Write Default Scope
</button>
</div>
</div>
<small class="form-text text-muted d-block mt-2">
The radio's own default region, stored in firmware and used for any
send the bot does not scope itself. Max 30 ASCII characters including
the <code>#</code>, which is added if you omit it. Blank clears it, and
the radio then floods unscoped.
</small>
<small class="form-text text-muted d-block mt-2">
<i class="fas fa-info-circle me-1"></i>
Once the bot sends a scoped message it leaves the radio in forced-unscoped
mode, so this default stops applying until the bot scopes another send.
If the bot should post under a region, set it in
<a href="#region-scopes-card">Region Scopes</a> below rather than relying
on this.
</small>
<div class="alert alert-warning py-2 px-3 small mt-2" id="nodeDefaultScopeNote"
style="display:none"></div>
<hr class="my-4">
<!-- Identity & Adverts -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Identity &amp; Adverts
@@ -317,6 +355,108 @@
</div>
</div>
<!-- Region Scopes -->
<div class="card mb-4" id="region-scopes-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-map-marked-alt me-2"></i>Region Scopes</h5>
<small class="text-muted" id="regionScopeTarget"></small>
</div>
<div class="card-body">
<div id="regionScopeAlert" style="display:none"></div>
<p class="text-muted small mb-4">
A region code confines a channel message to repeaters holding that
region's key, instead of flooding the whole mesh. These are bot
settings in <code>config.ini</code>, not device settings, so they
are not read from or written to the radio — the radio's own
<a href="#node-settings-card">Default Region Scope</a> is above, and
only covers sends the bot does not scope itself. See the
<a href="/region-warnings">Region Warnings</a> page for how much
unscoped traffic this mesh actually carries.
</p>
<div class="alert alert-warning py-2 px-3 small" id="regionScopeLegacy"
style="display:none"></div>
<!-- Incoming allowlist -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Which scopes the bot replies to
</h6>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="regionScopeMode"
id="regionScopeModeAll" value="all">
<label class="form-check-label" for="regionScopeModeAll">
Reply whatever the scope
<span class="text-muted">— every message the bot hears on a monitored channel.</span>
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="radio" name="regionScopeMode"
id="regionScopeModeList" value="list">
<label class="form-check-label" for="regionScopeModeList">
Only these scopes
<span class="text-muted">— reply mirrors the scope it came in on.</span>
</label>
</div>
<div class="ms-4 ps-2 border-start" id="regionScopeListFields">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="regionScopeList">Region scopes</label>
<input type="text" class="form-control" id="regionScopeList"
placeholder="e.g. #west, #east" autocomplete="off">
<small class="form-text text-muted">
Comma-separated. The leading <code>#</code> is added if you omit it.
</small>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-md-4">
<input class="form-check-input" type="checkbox" id="regionScopeAllowGlobal">
<label class="form-check-label" for="regionScopeAllowGlobal">
Also reply to unscoped (global) messages
<small class="d-block text-muted">
Adds <code>*</code>. Without it, a sender with no region set
gets no reply.
</small>
</label>
</div>
</div>
</div>
</div>
<hr class="my-4">
<!-- Outgoing default -->
<h6 class="text-uppercase text-muted fw-semibold mb-3" style="font-size:.75rem;letter-spacing:.05em;">
Default outgoing scope
</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="regionScopeOutgoing">Scope</label>
<input type="text" class="form-control" id="regionScopeOutgoing"
placeholder="blank = global flood" autocomplete="off">
<small class="form-text text-muted">
Used for sends that have no scope of their own — scheduled
messages, feeds, webhooks — and for a reply whose incoming
scope could not be matched. It never overrides a mirrored
reply scope.
</small>
</div>
<div class="col-md-8">
<div id="regionScopeChannelOverrides" class="small text-muted"></div>
</div>
</div>
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
<button type="button" class="btn btn-warning" id="saveRegionScopesBtn">
<span class="spinner-border spinner-border-sm me-1" id="saveRegionScopesSpinner"
style="display:none" role="status"></span>
<i class="fas fa-save me-1"></i>Save Region Scopes
</button>
<small id="regionScopeStatus" class="text-muted"></small>
</div>
</div>
</div>
<!-- Channel Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -426,8 +566,10 @@ class RadioManager {
this.setupEventHandlers();
this.setupRadioParamsHandlers();
this.setupNodeSettingsHandlers();
this.setupRegionScopeHandlers();
this.readRadioParams({ silent: true });
this.readFirmwareConfig({ silent: true });
this.loadRegionScopes();
// Auto-refresh every 30 seconds
setInterval(() => this.loadChannels(), 30000);
@@ -1285,6 +1427,8 @@ class RadioManager {
this.readFirmwareConfig();
});
document.getElementById('savePathHashBtn').addEventListener('click', () => this.savePathHash());
document.getElementById('saveDefaultScopeBtn')
.addEventListener('click', () => this.saveDefaultScope());
document.getElementById('saveIdentityBtn').addEventListener('click', () => this.saveIdentity());
document.getElementById('saveMeshBtn').addEventListener('click', () => this.saveMesh());
document.getElementById('applyTuningBtn').addEventListener('click', () => this.applyTuning());
@@ -1366,6 +1510,68 @@ class RadioManager {
if (data.path_hash_mode != null && data.path_hash_mode >= 0) {
document.getElementById('nodePathHashMode').value = String(data.path_hash_mode);
}
this.fillDefaultScope(data);
}
fillDefaultScope(data) {
const field = document.getElementById('nodeDefaultScope');
const keyNote = document.getElementById('nodeDefaultScopeKey');
const note = document.getElementById('nodeDefaultScopeNote');
keyNote.textContent = '';
note.style.display = 'none';
// Absent keys mean the radio never answered — older firmware has no
// CMD_GET_DEFAULT_FLOOD_SCOPE. Say so rather than showing an empty box
// that reads as "no scope set".
if (!data.default_scope_supported) {
field.value = '';
field.disabled = true;
document.getElementById('saveDefaultScopeBtn').disabled = true;
note.textContent = 'This radio did not report a default scope. The firmware '
+ 'is likely older than the setting; nothing was changed.';
note.style.display = 'block';
return;
}
field.disabled = false;
document.getElementById('saveDefaultScopeBtn').disabled = false;
const name = data.default_scope_name || '';
field.value = name;
if (!name) return;
keyNote.textContent = `Key ${(data.default_scope_key || '').slice(0, 8)}…`;
// The name is a label the app wrote; the key is what the radio routes
// by. They only disagree if something wrote them separately, and then
// the name on screen is not the region this radio is actually in.
if (data.default_scope_key && data.default_scope_key_matches === false) {
note.textContent = `The stored key does not match ${name}. The radio routes by `
+ 'the key, so the name shown is only a label. Writing the scope again '
+ 'puts the two back in step.';
note.style.display = 'block';
}
}
async saveDefaultScope() {
const raw = document.getElementById('nodeDefaultScope').value.trim();
if (!raw && !confirm(
'Clear the radio\'s default region scope?\n\n'
+ 'Sends the bot does not scope itself will then flood the whole mesh '
+ 'instead of staying inside a region.'
)) return;
await this.runNodeAction('saveDefaultScopeBtn', 'saveDefaultScopeSpinner', async () => {
const result = await this.queueAndPoll(
'/api/radio/firmware/config/write', { default_flood_scope: raw });
if (result && result.status === 'completed') {
this.setNodeAlert('success', raw
? `Default scope set to ${raw}.`
: 'Default scope cleared — the radio floods unscoped.');
// Read it back: the radio is the authority on what it stored.
await this.readFirmwareConfig({ silent: true });
} else {
this.setNodeAlert('danger', result?.error_message || 'Write failed');
}
});
}
async savePathHash() {
@@ -1485,6 +1691,213 @@ class RadioManager {
});
}
// ── Region scopes ([Channels] flood_scopes) ─────────────────────────────
setupRegionScopeHandlers() {
const sync = () => this.syncRegionScopeMode();
document.getElementById('regionScopeModeAll').addEventListener('change', sync);
document.getElementById('regionScopeModeList').addEventListener('change', sync);
document.getElementById('saveRegionScopesBtn')
.addEventListener('click', () => this.saveRegionScopes());
}
// The allowlist inputs are meaningless in "reply whatever the scope" mode,
// so disable rather than hide them: a hidden list the operator forgot they
// filled in is the version of this that loses their scopes on the next save.
syncRegionScopeMode() {
const listMode = document.getElementById('regionScopeModeList').checked;
document.getElementById('regionScopeList').disabled = !listMode;
document.getElementById('regionScopeAllowGlobal').disabled = !listMode;
document.getElementById('regionScopeListFields').style.opacity = listMode ? '1' : '.55';
}
async loadRegionScopes() {
try {
const resp = await fetch('/api/region-scopes', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await resp.json();
if (!resp.ok) {
this.setRegionScopeAlert('danger', data.error || 'Could not read region scopes');
return;
}
this.fillRegionScopeForm(data);
this.setRegionScopeAlert('', '');
} catch (e) {
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
}
}
fillRegionScopeForm(data) {
const scopes = Array.isArray(data.scopes) ? data.scopes : [];
document.getElementById('regionScopeList').value = scopes.join(', ');
document.getElementById('regionScopeAllowGlobal').checked = !!data.allow_global;
document.getElementById('regionScopeOutgoing').value = data.outgoing_override || '';
const listMode = !!data.allowlist_active;
document.getElementById('regionScopeModeList').checked = listMode;
document.getElementById('regionScopeModeAll').checked = !listMode;
this.syncRegionScopeMode();
const target = document.getElementById('regionScopeTarget');
target.textContent = data.target ? `[Channels] in ${data.target}` : '';
const legacy = document.getElementById('regionScopeLegacy');
if (data.legacy_section) {
// [Channels] is empty, so the bot fell back to the old location.
// Saving an allowlist here takes over; saving "reply whatever the
// scope" leaves [Channels] empty and hands it straight back.
legacy.textContent =
`[Channels] has no flood_scopes, so the bot is using the one under `
+ `[${data.legacy_section}]. Saving a list here writes [Channels], which `
+ `the bot reads first. Saving "reply whatever the scope" leaves [Channels] `
+ `empty, and the [${data.legacy_section}] entry takes over again — remove `
+ 'it from config.ini by hand to turn the allowlist off for good.';
legacy.style.display = 'block';
} else {
legacy.style.display = 'none';
}
this.renderChannelScopeOverrides(data.channel_overrides || []);
}
renderChannelScopeOverrides(overrides) {
const host = document.getElementById('regionScopeChannelOverrides');
host.replaceChildren();
if (!overrides.length) return;
const heading = document.createElement('div');
heading.className = 'fw-semibold mb-1';
heading.textContent = 'Per-channel overrides (edit in config.ini)';
host.appendChild(heading);
const list = document.createElement('ul');
list.className = 'list-unstyled mb-0';
overrides.forEach(entry => {
const item = document.createElement('li');
const name = document.createElement('code');
name.textContent = entry.channel;
item.appendChild(name);
item.appendChild(document.createTextNode(' → '));
const scope = document.createElement('code');
scope.textContent = entry.scope || 'global flood';
item.appendChild(scope);
list.appendChild(item);
});
host.appendChild(list);
const note = document.createElement('div');
note.className = 'mt-1';
note.textContent = 'These win over the default outgoing scope on their channel.';
host.appendChild(note);
}
async saveRegionScopes() {
const listMode = document.getElementById('regionScopeModeList').checked;
const payload = {
allowlist_enabled: listMode,
scopes: listMode ? document.getElementById('regionScopeList').value : '',
allow_global: listMode && document.getElementById('regionScopeAllowGlobal').checked,
outgoing_override: document.getElementById('regionScopeOutgoing').value,
};
if (listMode && !payload.allow_global) {
const ok = confirm(
'Only reply to the listed scopes?\n\n'
+ 'Anyone whose client has no region set floods unscoped, and the bot '
+ 'will not answer them at all. Tick "also reply to unscoped messages" '
+ 'to keep answering those.'
);
if (!ok) return;
}
const btn = document.getElementById('saveRegionScopesBtn');
const spinner = document.getElementById('saveRegionScopesSpinner');
btn.disabled = true;
spinner.style.display = 'inline-block';
this.setRegionScopeAlert('', '');
this.setRegionScopeStatus('Saving…', 'text-muted');
try {
const resp = await fetch('/api/region-scopes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok || !data.success) {
this.setRegionScopeStatus('', '');
this.setRegionScopeAlert('danger', data.error || 'Save failed');
return;
}
// Show what was actually written, including the canonical form of
// anything typed loosely (west -> #west, * folded into the checkbox).
this.fillRegionScopeForm(data.settings || {});
this.setRegionScopeStatus('Saved. Waiting for the bot to reload…', 'text-muted');
const applied = await this.awaitConfigReload(data.reload_operation_id);
this.setRegionScopeStatus(applied.text, applied.cls);
} catch (e) {
this.setRegionScopeStatus('', '');
this.setRegionScopeAlert('danger', 'Error: ' + e.message);
} finally {
btn.disabled = false;
spinner.style.display = 'none';
}
}
// A queued reload row only means the request was written down. Poll it so
// the page reports what the bot did, and says plainly when nothing picked
// it up (a stopped bot, or a reload the bot refused).
async awaitConfigReload(operationId) {
if (!operationId) {
return {
text: 'Saved to config.ini. It applies the next time the bot reloads.',
cls: 'text-warning'
};
}
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const resp = await fetch(`/api/channel-operations/${operationId}`);
const data = await resp.json();
if (data.status === 'completed') {
return { text: 'Saved and applied — the bot is using it now.', cls: 'text-success' };
}
if (data.status === 'failed' || data.status === 'interrupted') {
// reload_config's messages already end in a full stop.
const reason = String(data.error_message || 'reason not reported')
.trim().replace(/\.+$/, '');
return {
text: `Saved to config.ini, but the reload failed: ${reason}. `
+ 'Restart the bot to apply it.',
cls: 'text-danger'
};
}
} catch { /* keep polling; the answer is the same either way */ }
}
return {
text: 'Saved to config.ini. The bot has not picked it up — if it is not '
+ 'running, this applies at startup.',
cls: 'text-warning'
};
}
setRegionScopeAlert(type, message) {
const el = document.getElementById('regionScopeAlert');
if (!type || !message) { el.style.display = 'none'; return; }
el.className = `alert alert-${type}`;
el.textContent = message;
el.style.display = 'block';
}
setRegionScopeStatus(text, cls) {
const el = document.getElementById('regionScopeStatus');
el.className = cls || 'text-muted';
el.textContent = text || '';
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
@@ -0,0 +1,890 @@
{% extends "base.html" %}
{% block title %}Region Warnings - MeshCore Bot Data Viewer{% endblock %}
{% block extra_css %}
<style>
/* Theme-aware tokens. Bootstrap's .text-warning (#ffc107) is 1.6:1 on a
white card, and its chart-friendly oranges/grays are picked for dark
surfaces, so each value is chosen per theme against the surface it
actually lands on. Fills clear 3:1 against the card; text clears 4.5:1. */
:root {
--rw-alert: #b45309; /* 5.0:1 on white */
--rw-global: #e8590c; /* 3.6:1 on white */
--rw-scoped: #198754; /* 4.5:1 on white */
--rw-unknown: #868e96; /* 3.3:1 on white */
}
[data-theme="dark"] {
--rw-alert: #fdba74; /* 8.2:1 on the dark card */
--rw-global: #fd7e14; /* 5.4:1 */
--rw-scoped: #2fa36b; /* 4.4:1 */
--rw-unknown: #9ca3af; /* 5.4:1 */
}
.rw-alert { color: var(--rw-alert); }
.rw-status {
border-left: 4px solid var(--border-color);
}
.rw-status.rw-status--off { border-left-color: #6c757d; }
.rw-status.rw-status--dry { border-left-color: #0d6efd; }
.rw-status.rw-status--live { border-left-color: var(--rw-global); }
.rw-figure {
font-size: 2rem;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.rw-figure-label {
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
}
/* Stacked proportion bar. Every segment also carries its number in the
table beside it, so the colors are a summary and never the only signal. */
.rw-bar {
display: flex;
height: 10px;
border-radius: 5px;
overflow: hidden;
background-color: var(--bg-tertiary);
}
.rw-bar span { display: block; height: 100%; }
.rw-seg-global { background-color: var(--rw-global); }
.rw-seg-scoped { background-color: var(--rw-scoped); }
.rw-seg-unknown { background-color: var(--rw-unknown); }
/* Keep a swatch glued to its label: a wrap between them leaves an
unlabelled dot at the end of a line on narrow screens. */
.rw-legend > span {
display: inline-block;
white-space: nowrap;
margin-right: 1rem;
}
/* The gray band only appears in the daily columns, so its legend swatch is
the one place the channel bars and that chart have to agree. */
.rw-unknown-note::before {
content: '';
display: inline-block;
width: .6rem;
height: .6rem;
border-radius: 2px;
margin-right: .3rem;
background-color: var(--rw-unknown);
}
.rw-legend-dot {
display: inline-block;
width: .65rem;
height: .65rem;
border-radius: 2px;
margin-right: .35rem;
vertical-align: baseline;
}
/* Day columns. The track is transparent on purpose: a filled one would
read as another stacked segment next to the gray "couldn't tell" band. */
.rw-days {
display: flex;
align-items: flex-end;
gap: 3px;
height: 84px;
border-bottom: 1px solid var(--border-color);
}
.rw-day {
flex: 1 1 0;
min-width: 6px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.rw-day > div {
border-radius: 3px 3px 0 0;
overflow: hidden;
}
.rw-day span { display: block; width: 100%; }
.rw-channel-name {
font-family: var(--bs-font-monospace, monospace);
white-space: nowrap;
}
/* Clamp each decision's text to two lines: the warning wording repeats on
every row, so at full height it buries the columns that actually differ.
Wrapping rather than a nowrap ellipsis keeps the table narrow enough that
a phone scrolls the page instead of the table's own horizontal overflow. */
.rw-event-detail {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-width: 12rem;
}
.rw-modes { max-width: 34rem; }
.rw-mode-option {
display: flex;
gap: .75rem;
align-items: flex-start;
padding: .7rem .85rem;
border: 1px solid var(--border-color);
border-radius: .375rem;
cursor: pointer;
background-color: var(--bg-color);
}
.rw-mode-option + .rw-mode-option { margin-top: .5rem; }
.rw-mode-option:hover { background-color: var(--bg-secondary); }
.rw-mode-option:focus-within {
border-color: #0d6efd;
box-shadow: 0 0 0 .2rem rgba(13, 110, 253, .25);
}
.rw-mode-option input { margin-top: .25rem; flex: none; }
.rw-mode-option.rw-mode-selected {
border-color: #0d6efd;
background-color: var(--bg-secondary);
}
.rw-preview {
background-color: var(--log-entry-bg);
border: 1px solid var(--border-color);
border-radius: .375rem;
padding: .6rem .75rem;
font-family: var(--bs-font-monospace, monospace);
font-size: .875rem;
white-space: pre-wrap;
word-break: break-word;
min-height: 3.2rem;
}
.rw-empty {
color: var(--text-muted);
text-align: center;
padding: 1.75rem .5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-1"><i class="fas fa-globe"></i> Region Warnings</h1>
<p class="text-muted mb-4">
A MeshCore client with no region code floods every message to the whole mesh.
This page counts how much of that your bot hears, and can optionally tell those senders.
</p>
</div>
</div>
<!-- What the bot is doing right now -->
<div class="row mb-4">
<div class="col-12">
<div class="card rw-status rw-status--off" id="rw-status-card">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="flex-grow-1">
<h2 class="h5 mb-1" id="rw-status-title">Loading…</h2>
<div class="text-muted small" id="rw-status-detail">Reading current settings.</div>
</div>
<div class="text-lg-end" id="rw-status-budget"></div>
</div>
</div>
</div>
</div>
<!-- Evidence -->
<div class="row mb-4">
<div class="col-lg-8 mb-4 mb-lg-0">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="fas fa-chart-simple"></i> Channel traffic by scope</span>
<div>
<label class="visually-hidden" for="rw-window">Window</label>
<select class="form-select form-select-sm" id="rw-window">
<option value="7">Last 7 days</option>
<option value="14" selected>Last 14 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
</div>
<div class="card-body">
<div class="mb-3 small rw-legend">
<span><span class="rw-legend-dot rw-seg-global"></span>No region code</span>
<span><span class="rw-legend-dot rw-seg-scoped"></span>Region code set</span>
<span><span class="rw-legend-dot rw-seg-unknown"></span>Couldn't tell (not in the bars)</span>
</div>
<div id="rw-traffic"><div class="loading">Loading traffic…</div></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header"><i class="fas fa-calendar-day"></i> Daily volume</div>
<div class="card-body">
<div id="rw-days-wrap">
<div class="rw-days" id="rw-days"></div>
<div class="d-flex justify-content-between small text-muted mt-2" id="rw-days-range"></div>
<hr>
</div>
<div id="rw-totals"></div>
</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="row mb-4">
<div class="col-12">
<form class="card" id="rw-form" novalidate>
<div class="card-header"><i class="fas fa-sliders-h"></i> Settings</div>
<div class="card-body">
<fieldset class="mb-4">
<legend class="h6">What the bot does</legend>
<div class="rw-modes" role="radiogroup" aria-label="Region warning mode">
<label class="rw-mode-option" for="rw-mode-off">
<input class="form-check-input" type="radio" name="rw-mode" id="rw-mode-off" value="off">
<span>
<strong>Count only</strong>
<span class="d-block text-muted small">Tally scopes for this page. Nothing is ever transmitted.</span>
</span>
</label>
<label class="rw-mode-option" for="rw-mode-dry">
<input class="form-check-input" type="radio" name="rw-mode" id="rw-mode-dry" value="dry">
<span>
<strong>Dry run</strong>
<span class="d-block text-muted small">Decide warnings and log them below, but transmit nothing. Cooldowns and the daily cap apply, so the log shows exactly what going live would send &mdash; which also means a previewed sender stays on cooldown after you go live.</span>
</span>
</label>
<label class="rw-mode-option" for="rw-mode-live">
<input class="form-check-input" type="radio" name="rw-mode" id="rw-mode-live" value="live">
<span>
<strong>Send warnings</strong>
<span class="d-block text-muted small">Spends airtime automatically, within the limits below.</span>
</span>
</label>
</div>
<div class="small text-muted mt-2" id="rw-mode-note" hidden>
Everything below is still saved while the bot is only counting;
it just isn't used until you pick dry run or send.
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="rw-track-traffic" checked>
<label class="form-check-label" for="rw-track-traffic">
Keep counting scopes
<span class="d-block text-muted small">Off stops the tallies above. Warnings still work; you just lose the evidence.</span>
</label>
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="h6">Who hears it</legend>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="rw-delivery">Delivery</label>
<select class="form-select" id="rw-delivery">
<option value="dm">Direct message to the sender</option>
<option value="channel">Reply on the channel</option>
</select>
<div class="form-text" id="rw-delivery-help"></div>
</div>
<div class="col-md-8">
<label class="form-label" for="rw-channels">Channels to watch</label>
<input type="text" class="form-control" id="rw-channels"
placeholder="Leave empty for every channel"
aria-describedby="rw-channels-help">
<div class="form-text" id="rw-channels-help">
Comma-separated. The <code>#</code> is optional.
</div>
<div class="mt-2" id="rw-channel-chips"></div>
</div>
</div>
</fieldset>
<fieldset class="mb-4">
<legend class="h6">Limits</legend>
<div class="row g-3">
<div class="col-sm-6 col-xl-3">
<label class="form-label" for="rw-min-unscoped">Warn after</label>
<div class="input-group">
<input type="number" class="form-control" id="rw-min-unscoped" min="1" max="100" step="1">
<span class="input-group-text">msgs</span>
</div>
<div class="form-text">Unscoped messages from one sender.</div>
</div>
<div class="col-sm-6 col-xl-3">
<label class="form-label" for="rw-sender-cooldown">Per-sender cooldown</label>
<div class="input-group">
<input type="number" class="form-control" id="rw-sender-cooldown" min="0" max="8760" step="1">
<span class="input-group-text">hours</span>
</div>
<div class="form-text">0 disables.</div>
</div>
<div class="col-sm-6 col-xl-3">
<label class="form-label" for="rw-mesh-cooldown">Mesh-wide cooldown</label>
<div class="input-group">
<input type="number" class="form-control" id="rw-mesh-cooldown" min="0" max="10080" step="1">
<span class="input-group-text">min</span>
</div>
<div class="form-text">Minimum gap between any two warnings.</div>
</div>
<div class="col-sm-6 col-xl-3">
<label class="form-label" for="rw-daily-cap">Daily cap</label>
<input type="number" class="form-control" id="rw-daily-cap" min="0" max="1000" step="1">
<div class="form-text">0 means unlimited.</div>
</div>
</div>
</fieldset>
<fieldset>
<legend class="h6">Message</legend>
<div class="row g-3">
<div class="col-lg-7">
<label class="form-label" for="rw-message">Warning text</label>
<textarea class="form-control" id="rw-message" rows="3"
aria-describedby="rw-message-help"></textarea>
<div class="form-text" id="rw-message-help">
<code>{sender}</code> and <code>{channel}</code> are substituted.
</div>
<button type="button" class="btn btn-link btn-sm px-0" id="rw-reset-message">
Reset to the default wording
</button>
</div>
<div class="col-lg-5">
<span class="form-label d-block">Preview</span>
<div class="rw-preview" id="rw-preview"></div>
<div class="small mt-2" id="rw-bytes"></div>
<div class="small text-muted" id="rw-preview-note"></div>
</div>
</div>
</fieldset>
</div>
<div class="card-footer d-flex flex-wrap align-items-center gap-3">
<button type="submit" class="btn btn-primary" id="rw-save">Save settings</button>
<button type="button" class="btn btn-outline-secondary" id="rw-revert">Revert</button>
<span id="rw-save-status" role="status" aria-live="polite"></span>
</div>
</form>
</div>
</div>
<!-- Log -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list-check"></i> Recent warning decisions</span>
<button class="btn btn-sm btn-outline-secondary" type="button" id="rw-refresh">
<i class="fas fa-rotate"></i> Refresh
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead class="table-dark">
<tr>
<th scope="col">When</th>
<th scope="col">Sender</th>
<th scope="col">Channel</th>
<th scope="col">Outcome</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody id="rw-events">
<tr><td colspan="5" class="rw-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';
const SAMPLE_SENDER = 'Wandering Node';
const SAMPLE_CHANNEL = 'general';
const el = (id) => document.getElementById(id);
const encoder = new TextEncoder();
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function plural(n, word) {
return n + ' ' + word + (n === 1 ? '' : 's');
}
class RegionWarnings {
constructor() {
this.data = null;
this.windowDays = 14;
this.bindEvents();
this.load();
}
bindEvents() {
el('rw-window').addEventListener('change', (e) => {
this.windowDays = parseInt(e.target.value, 10) || 14;
this.load();
});
el('rw-refresh').addEventListener('click', () => this.load());
el('rw-form').addEventListener('submit', (e) => {
e.preventDefault();
this.save();
});
el('rw-revert').addEventListener('click', () => {
if (this.data) {
this.fillForm(this.data.settings);
this.setStatus('Reverted to the saved settings.', 'text-muted');
}
});
el('rw-reset-message').addEventListener('click', () => {
if (!this.data) return;
el('rw-message').value = this.data.default_message || '';
this.refreshPreview();
});
el('rw-message').addEventListener('input', () => this.refreshPreview());
el('rw-delivery').addEventListener('change', () => this.refreshPreview());
el('rw-channels').addEventListener('input', () => this.markSelectedChips());
document.querySelectorAll('input[name="rw-mode"]').forEach((input) => {
input.addEventListener('change', () => this.refreshMode());
});
}
async load() {
try {
const resp = await fetch('/api/region-warnings?days=' + this.windowDays, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!resp.ok) throw new Error('Request failed (' + resp.status + ')');
this.data = await resp.json();
} catch (err) {
el('rw-status-title').textContent = 'Could not load region warning data';
el('rw-status-detail').textContent = err.message;
return;
}
this.fillForm(this.data.settings);
this.renderStatus();
this.renderTraffic();
this.renderDays();
this.renderChannelChips();
this.renderEvents();
}
// -- form ------------------------------------------------------------
currentMode() {
const checked = document.querySelector('input[name="rw-mode"]:checked');
return checked ? checked.value : 'off';
}
fillForm(settings) {
const isTrue = (v) => String(v).toLowerCase() === 'true';
const enabled = isTrue(settings.enabled);
const dryRun = isTrue(settings.dry_run);
const mode = !enabled ? 'off' : (dryRun ? 'dry' : 'live');
el('rw-mode-' + mode).checked = true;
el('rw-track-traffic').checked = isTrue(settings.track_traffic);
el('rw-delivery').value = settings.delivery || 'dm';
el('rw-channels').value = settings.channels || '';
el('rw-min-unscoped').value = settings.min_unscoped_messages;
el('rw-sender-cooldown').value = settings.per_sender_cooldown_hours;
el('rw-mesh-cooldown').value = settings.mesh_cooldown_minutes;
el('rw-daily-cap').value = settings.max_warnings_per_day;
el('rw-message').value = settings.message || '';
this.refreshMode();
this.markSelectedChips();
}
refreshMode() {
const mode = this.currentMode();
document.querySelectorAll('.rw-mode-option').forEach((option) => {
const input = option.querySelector('input');
option.classList.toggle('rw-mode-selected', !!input && input.checked);
});
// The rest of the form describes warnings, which "count only" never
// sends — but the values are still written, and disabling the fields
// would both say otherwise and stop anyone drafting their wording
// before they turn it on. Say it in a sentence instead.
el('rw-mode-note').hidden = mode !== 'off';
this.refreshPreview();
}
refreshPreview() {
const limits = (this.data && this.data.limits) || { dm: 158, channel: 130 };
const delivery = el('rw-delivery').value;
const limit = delivery === 'channel' ? limits.channel : limits.dm;
const text = (el('rw-message').value || '')
.replace(/\{sender\}/g, SAMPLE_SENDER)
.replace(/\{channel\}/g, SAMPLE_CHANNEL)
.trim();
el('rw-preview').textContent = text || '(no message)';
const bytes = encoder.encode(text).length;
const over = bytes > limit;
const label = delivery === 'channel' ? 'channel reply' : 'direct message';
el('rw-bytes').innerHTML = over
? '<span class="text-danger"><i class="fas fa-triangle-exclamation"></i> ' + bytes
+ ' of ' + limit + ' bytes — the end will be cut off in a ' + label + '.</span>'
: '<span class="text-muted">' + bytes + ' of ' + limit + ' bytes in a ' + label + '.</span>';
const raw = el('rw-message').value || '';
const used = [];
if (raw.indexOf('{sender}') >= 0) used.push('{sender} = ' + SAMPLE_SENDER);
if (raw.indexOf('{channel}') >= 0) used.push('{channel} = ' + SAMPLE_CHANNEL);
el('rw-preview-note').textContent = used.length ? 'Shown with ' + used.join(', ') + '.' : '';
el('rw-delivery-help').textContent = delivery === 'channel'
? 'Sent at global scope so it reaches someone outside your region — everyone on the channel sees it.'
: 'Costs the least airtime, and only the sender sees it. Only goes to '
+ 'senders the radio already has a contact for.';
}
async save() {
const mode = this.currentMode();
if (mode === 'live') {
const unfenced = [
Number(el('rw-mesh-cooldown').value) === 0 && 'no gap between warnings',
Number(el('rw-sender-cooldown').value) === 0 && 'no per-sender cooldown',
Number(el('rw-daily-cap').value) === 0 && 'no daily cap',
].filter(Boolean);
let text = 'Send warnings for real?\n\n'
+ 'The bot will transmit unprompted on the mesh whenever a sender '
+ 'meets the limits below.';
if (unfenced.length) {
// All three at zero is three clicks away and would let one
// chatty channel turn into a burst of DMs.
text += '\n\nWith ' + unfenced.join(', ')
+ ', nothing bounds how many go out.';
}
const ok = window.confirm(text);
if (!ok) return;
}
const payload = {
enabled: mode !== 'off',
dry_run: mode !== 'live',
track_traffic: el('rw-track-traffic').checked,
delivery: el('rw-delivery').value,
channels: el('rw-channels').value,
message: el('rw-message').value,
min_unscoped_messages: el('rw-min-unscoped').value,
per_sender_cooldown_hours: el('rw-sender-cooldown').value,
mesh_cooldown_minutes: el('rw-mesh-cooldown').value,
max_warnings_per_day: el('rw-daily-cap').value,
};
const button = el('rw-save');
button.disabled = true;
this.setStatus('Saving…', 'text-muted');
try {
const resp = await fetch('/api/region-warnings/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
const result = await resp.json();
if (!resp.ok || !result.success) {
throw new Error(result.error || 'Save failed');
}
// Re-read rather than patch in place: the status strip's budget
// is derived from the cap that was just changed.
await this.load();
this.setStatus(
result.reload_queued
? 'Saved. The bot picks this up shortly.'
: 'Saved to config.ini. Restart the bot to apply.',
'text-success',
);
} catch (err) {
this.setStatus(err.message, 'text-danger');
} finally {
button.disabled = false;
}
}
setStatus(text, cls) {
const node = el('rw-save-status');
node.className = cls || '';
node.textContent = text;
}
// -- rendering -------------------------------------------------------
renderStatus() {
const s = this.data.settings;
const budget = this.data.budget || {};
const enabled = String(s.enabled).toLowerCase() === 'true';
const dryRun = String(s.dry_run).toLowerCase() === 'true';
const card = el('rw-status-card');
card.classList.remove('rw-status--off', 'rw-status--dry', 'rw-status--live');
let title;
let detail;
if (!enabled) {
card.classList.add('rw-status--off');
title = 'Counting only — nothing is transmitted';
detail = String(s.track_traffic).toLowerCase() === 'true'
? 'Scopes are tallied below so you can see the problem before spending airtime on it.'
: 'Scope tallying is off too, so this page will stop gaining new data.';
} else {
const how = s.delivery === 'channel' ? 'a channel reply' : 'a direct message';
const where = s.channels ? 'on ' + s.channels : 'on every channel';
if (dryRun) {
card.classList.add('rw-status--dry');
title = 'Dry run — warnings are decided but not sent';
detail = 'Would send ' + how + ' ' + where
+ ' after ' + plural(parseInt(s.min_unscoped_messages, 10), 'unscoped message') + '.';
} else {
card.classList.add('rw-status--live');
title = 'Sending warnings';
detail = 'Sends ' + how + ' ' + where
+ ' after ' + plural(parseInt(s.min_unscoped_messages, 10), 'unscoped message') + '.';
}
}
el('rw-status-title').textContent = title;
el('rw-status-detail').textContent = detail;
if (!enabled) {
el('rw-status-budget').innerHTML = '';
return;
}
const used = budget.used_today || 0;
const failed = budget.failed_today || 0;
const withheld = budget.withheld_today || 0;
// Dry runs and real sends are different events and must not share a
// figure: a morning's preview would otherwise read as transmissions.
const shown = dryRun
? (budget.previewed_today || 0)
: (budget.delivered_today || 0);
// The cap is a ceiling on new warnings, not a rewrite of history: a
// lowered cap can leave today already over it, and "7 of 6" would
// read as a bug rather than as "no more today".
let capLine;
if (budget.unlimited) {
capLine = '<span class="rw-alert">No daily cap</span>';
} else if (used >= budget.cap) {
capLine = '<span class="rw-alert">Daily cap of ' + budget.cap + ' reached</span>';
} else {
capLine = escapeHtml(budget.remaining + ' of ' + budget.cap + ' left today');
}
const last = budget.last_warning_at
? 'Last at ' + escapeHtml(budget.last_warning_at)
: 'None yet';
// The big number is delivered, not attempted: a count of four beside
// "last warning: none yet" reads as a bug. Attempts still show,
// because they are what the cap actually spends.
el('rw-status-budget').innerHTML =
'<div class="rw-figure">' + shown + '</div>'
+ '<div class="rw-figure-label">'
+ (dryRun ? 'would send today' : 'sent today') + '</div>'
+ (failed
? '<div class="small rw-alert">' + plural(failed, 'failed attempt')
+ ', also counted</div>'
: '')
+ (withheld
? '<div class="small rw-alert">' + withheld + ' withheld: '
+ (withheld === 1 ? 'no contact by that name' : 'no contact by those names')
+ '</div>'
: '')
+ '<div class="small mt-1">' + capLine + '</div>'
+ '<div class="small text-muted">' + last + '</div>';
}
renderTraffic() {
const traffic = this.data.traffic || {};
const channels = traffic.channels || [];
const host = el('rw-traffic');
if (!channels.length) {
host.innerHTML = '<div class="rw-empty">'
+ 'No channel messages classified yet. Numbers appear here as the bot hears traffic.'
+ '</div>';
return;
}
const rows = channels.map((c) => {
// The bar spans the classified messages only, so its orange width
// IS the percentage printed beside it. Scaling it over the total
// instead put a 28.7% segment next to the label "33.7%".
const classified = c.scoped + c.global;
const pct = (n) => (classified ? (100 * n / classified) : 0).toFixed(2) + '%';
const share = c.unscoped_pct == null
? '<span class="text-muted">not enough data</span>'
: '<strong>' + c.unscoped_pct + '%</strong> with no region code';
const coverage = c.total
? Math.round(100 * c.unknown / c.total) : 0;
return ''
+ '<div class="mb-3">'
+ ' <div class="d-flex justify-content-between align-items-baseline gap-2">'
+ ' <span class="rw-channel-name">' + escapeHtml(c.channel) + '</span>'
+ ' <span class="small text-muted">' + share + '</span>'
+ ' </div>'
+ ' <div class="rw-bar my-1" role="img" aria-label="'
+ escapeHtml(c.channel) + ': ' + c.global + ' unscoped of '
+ classified + ' classified, ' + c.unknown + ' unclassified">'
+ ' <span class="rw-seg-global" style="width:' + pct(c.global) + '"></span>'
+ ' <span class="rw-seg-scoped" style="width:' + pct(c.scoped) + '"></span>'
+ ' </div>'
+ ' <div class="small text-muted">'
+ c.global + ' unscoped &middot; ' + c.scoped + ' scoped'
+ (c.unknown
? ' &middot; <span class="rw-unknown-note">' + c.unknown
+ " couldn't tell (" + coverage + '% of traffic)</span>'
: '')
+ ' </div>'
+ '</div>';
});
host.innerHTML = rows.join('');
}
renderDays() {
const series = this.data.series || [];
const host = el('rw-days');
const peak = series.reduce(
(max, d) => Math.max(max, d.scoped + d.global + d.unknown), 0);
// An empty 84px track plus a date range reads as a broken chart
// rather than as "no data"; the summary below says it in words.
el('rw-days-wrap').hidden = peak === 0;
host.innerHTML = series.map((d) => {
const total = d.scoped + d.global + d.unknown;
const height = peak ? Math.max(2, Math.round(100 * total / peak)) : 0;
const seg = (n) => (total ? (100 * n / total) : 0).toFixed(2) + '%';
const label = d.date + ': ' + d.global + ' unscoped, ' + d.scoped
+ ' scoped, ' + d.unknown + " couldn't tell";
return '<div class="rw-day" title="' + escapeHtml(label) + '">'
+ '<div style="height:' + height + '%" aria-hidden="true">'
+ '<span class="rw-seg-global" style="height:' + seg(d.global) + '"></span>'
+ '<span class="rw-seg-scoped" style="height:' + seg(d.scoped) + '"></span>'
+ '<span class="rw-seg-unknown" style="height:' + seg(d.unknown) + '"></span>'
+ '</div></div>';
}).join('');
if (series.length) {
el('rw-days-range').innerHTML =
'<span>' + escapeHtml(series[0].date) + '</span>'
+ '<span>' + escapeHtml(series[series.length - 1].date) + '</span>';
} else {
el('rw-days-range').innerHTML = '';
}
el('rw-days').setAttribute(
'aria-label',
'Daily message volume by scope over the last ' + series.length + ' days.');
const totals = (this.data.traffic || {}).totals || {};
const classified = (totals.scoped || 0) + (totals.global || 0);
const num = (n) => Number(n || 0).toLocaleString();
if (!classified && !totals.unknown) {
el('rw-totals').innerHTML =
'<div class="text-muted small">Nothing classified in this window yet.</div>';
return;
}
el('rw-totals').innerHTML = ''
+ '<div class="rw-figure rw-alert">'
+ (totals.unscoped_pct == null ? '—' : totals.unscoped_pct + '%') + '</div>'
+ '<div class="text-muted">of classified traffic had no region code</div>'
+ '<div class="small text-muted mt-2">'
+ num(totals.global) + ' unscoped of ' + num(classified) + ' classified'
+ (totals.unknown
? ' &middot; ' + num(totals.unknown) + " the radio couldn't classify"
: '')
+ '</div>';
}
renderChannelChips() {
const known = this.data.known_channels || [];
const host = el('rw-channel-chips');
if (!known.length) {
host.innerHTML = '';
return;
}
host.innerHTML = known.map((name) =>
'<button type="button" class="btn btn-sm btn-outline-secondary me-1 mb-1" '
+ 'data-channel="' + escapeHtml(name) + '">' + escapeHtml(name) + '</button>'
).join('');
host.querySelectorAll('button[data-channel]').forEach((button) => {
button.addEventListener('click', () => {
const input = el('rw-channels');
const raw = button.dataset.channel.replace(/^#/, '').toLowerCase();
const parts = this.selectedChannels();
const at = parts.indexOf(raw);
if (at >= 0) parts.splice(at, 1); else parts.push(raw);
input.value = parts.join(', ');
this.markSelectedChips();
});
});
this.markSelectedChips();
}
selectedChannels() {
return el('rw-channels').value.split(',')
.map((p) => p.trim().replace(/^#/, '').toLowerCase())
.filter(Boolean);
}
// A chip that is already in the allowlist has to look different from one
// that is not, or clicking it is a coin flip between adding and removing.
markSelectedChips() {
const selected = this.selectedChannels();
document.querySelectorAll('#rw-channel-chips button[data-channel]')
.forEach((button) => {
const raw = button.dataset.channel.replace(/^#/, '').toLowerCase();
const on = selected.includes(raw);
button.classList.toggle('btn-secondary', on);
button.classList.toggle('btn-outline-secondary', !on);
button.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
renderEvents() {
const events = this.data.events || [];
const body = el('rw-events');
if (!events.length) {
const withheld = (this.data.budget || {}).withheld_today || 0;
body.innerHTML = '<tr><td colspan="5" class="rw-empty">'
+ (withheld
// "Nothing happened" and "everything was dropped" look
// identical from an empty table; say which it is.
? escapeHtml(plural(withheld, 'warning'))
+ ' withheld today because no contact matched the sender. '
+ 'Direct messages only go to names the radio knows &mdash; '
+ 'switch delivery to the channel to reach everyone.'
: 'No warnings decided yet. Turn on dry run to see what would be sent.')
+ '</td></tr>';
return;
}
const badge = {
sent: '<span class="badge bg-success">Sent</span>',
dry_run: '<span class="badge bg-primary">Dry run</span>',
failed: '<span class="badge bg-danger">Failed</span>',
};
body.innerHTML = events.map((e) => ''
+ '<tr>'
+ '<td class="text-nowrap small">' + escapeHtml(e.created_at) + '</td>'
+ '<td>' + escapeHtml(e.sender_id) + '</td>'
+ '<td class="rw-channel-name small">' + escapeHtml(e.channel || '—') + '</td>'
+ '<td class="text-nowrap">' + (badge[e.action] || escapeHtml(e.action))
+ ' <span class="text-muted small text-uppercase">'
+ escapeHtml(e.delivery) + '</span></td>'
+ '<td class="small"><span class="rw-event-detail" title="'
+ escapeHtml(e.detail || '') + '">' + escapeHtml(e.detail || '') + '</span></td>'
+ '</tr>'
).join('');
}
}
document.addEventListener('DOMContentLoaded', () => new RegionWarnings());
})();
</script>
{% endblock %}
+1
View File
@@ -156,6 +156,7 @@ module = [
"modules.security_utils",
"modules.commands.base_command",
"modules.commands.rain_command",
"modules.region_warning",
]
disallow_untyped_defs = true
disallow_incomplete_defs = true
+365
View File
@@ -0,0 +1,365 @@
"""Tests for the Radio Settings page's region-scope editor and its API."""
from __future__ import annotations
import configparser
import json
from unittest.mock import patch
import pytest
from modules import flood_scope
@pytest.fixture
def viewer(tmp_path):
"""BotDataViewer against a temp config + empty DB (migrations create tables)."""
from modules.web_viewer.app import BotDataViewer
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "db_path", str(tmp_path / "meshcore_bot.db"))
config.set("Bot", "bot_name", "TestBot")
config.add_section("Channels")
config.set("Channels", "monitor_channels", "general")
config.add_section("Web_Viewer")
for key, value in [
("host", "127.0.0.1"), ("port", "8080"), ("enabled", "false"),
("auto_start", "false"), ("debug", "false"),
("cors_allowed_origins", "*"), ("web_viewer_password", ""),
]:
config.set("Web_Viewer", key, value)
config_path = str(tmp_path / "config.ini")
with open(config_path, "w") as handle:
config.write(handle)
with patch.object(BotDataViewer, "_start_database_polling"), \
patch.object(BotDataViewer, "_start_log_tailing"), \
patch.object(BotDataViewer, "_start_cleanup_scheduler"), \
patch.object(BotDataViewer, "_start_dashboard_refresher"), \
patch.object(BotDataViewer, "_setup_socketio_handlers"), \
patch("modules.web_viewer.app.RepeaterManager"):
instance = BotDataViewer(
db_path=str(tmp_path / "meshcore_bot.db"), config_path=config_path)
instance.app.testing = True
return instance
def _written(tmp_path, path="config.ini"):
parser = configparser.ConfigParser()
parser.read(tmp_path / path, encoding="utf-8")
return parser
class TestScopeNames:
def test_a_bare_name_gains_the_hash(self):
assert flood_scope.validate_scope_name("west") == "#west"
def test_spaces_inside_a_name_are_kept(self):
"""The bot hashes the name whole, so '#north east' is a real region."""
assert flood_scope.validate_scope_name("north east") == "#north east"
def test_global_markers_are_canonicalised(self):
assert flood_scope.validate_scope_name("*") == "*"
assert flood_scope.validate_scope_name("none") == "None"
assert flood_scope.validate_scope_name("") == ""
def test_a_percent_is_refused(self):
"""config.ini is read with interpolation on, so a bare % breaks every
later read of [Channels] — including the bot's hot reloads."""
with pytest.raises(ValueError, match="%"):
flood_scope.validate_scope_name("100%west")
def test_a_comma_is_refused(self):
with pytest.raises(ValueError):
flood_scope.validate_scope_name("west,east")
def test_an_inner_hash_is_refused(self):
with pytest.raises(ValueError):
flood_scope.validate_scope_name("#west#east")
def test_a_newline_is_refused(self):
with pytest.raises(ValueError):
flood_scope.validate_scope_name("west\neast = oops")
def test_a_bare_hash_has_no_name(self):
with pytest.raises(ValueError):
flood_scope.validate_scope_name("#")
def test_an_overlong_name_is_refused(self):
with pytest.raises(ValueError):
flood_scope.validate_scope_name("w" * flood_scope.MAX_SCOPE_NAME_LENGTH)
def test_the_allowlist_split_matches_the_bot(self):
"""Duplicates collapse and global markers become the flag, not a key —
the same shape CommandManager._load_flood_scope_keys produces."""
assert flood_scope.split_allowlist("west, #west, *, east") == (
["#west", "#east"], True)
assert flood_scope.split_allowlist("#west") == (["#west"], False)
assert flood_scope.split_allowlist("") == ([], False)
class TestRegionScopesGet:
def test_defaults_are_reply_to_everything(self, viewer):
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["allowlist_active"] is False
assert data["scopes"] == []
assert data["allow_global"] is False
assert data["outgoing_override"] == ""
assert data["target"] == "config.ini"
def test_configured_values_are_reported_canonically(self, viewer, tmp_path):
config_path = tmp_path / "config.ini"
text = config_path.read_text(encoding="utf-8")
text = text.replace(
"[Channels]",
"[Channels]\nflood_scopes = west, #east, *\n"
"outgoing_flood_scope_override = west\n",
)
config_path.write_text(text, encoding="utf-8")
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["allowlist_active"] is True
assert data["scopes"] == ["#west", "#east"]
assert data["allow_global"] is True
assert data["outgoing_override"] == "#west"
def test_a_global_override_reads_back_as_blank(self, viewer, tmp_path):
"""'*', '0' and 'None' all mean global flood; the page shows one thing."""
config_path = tmp_path / "config.ini"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"[Channels]", "[Channels]\noutgoing_flood_scope_override = None\n"),
encoding="utf-8",
)
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["outgoing_override"] == ""
def test_flood_scopes_left_in_the_bot_section_is_surfaced(self, viewer, tmp_path):
"""CommandManager still honours it there, so hiding it would show
'replies to every scope' while the bot enforces an allowlist."""
config_path = tmp_path / "config.ini"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"[Bot]", "[Bot]\nflood_scopes = #west\n"),
encoding="utf-8",
)
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["legacy_section"] == "Bot"
assert data["scopes"] == ["#west"]
def test_per_channel_overrides_are_listed(self, viewer, tmp_path):
config_path = tmp_path / "config.ini"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"[Channels]",
"[Channels]\nflood_scope.weather = #sea\nflood_scope.general = *\n"),
encoding="utf-8",
)
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["channel_overrides"] == [
{"channel": "general", "scope": ""},
{"channel": "weather", "scope": "#sea"},
]
class TestRegionScopesSave:
def test_save_writes_config_and_queues_a_reload(self, viewer, tmp_path):
resp = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True,
"scopes": "west, east",
"allow_global": True,
"outgoing_override": "west",
})
assert resp.status_code == 200
body = resp.get_json()
assert body["success"] is True
assert body["reload_queued"] is True
assert body["reload_operation_id"]
written = _written(tmp_path)
assert written.get("Channels", "flood_scopes") == "#west, #east, *"
assert written.get("Channels", "outgoing_flood_scope_override") == "#west"
def test_the_queued_reload_is_a_real_operation_row(self, viewer):
op_id = viewer.app.test_client().post(
"/api/region-scopes",
json={"allowlist_enabled": False},
).get_json()["reload_operation_id"]
rows = viewer.db_manager.execute_query(
"SELECT operation_type, status FROM channel_operations WHERE id = ?", (op_id,))
assert rows[0]["operation_type"] == "config_reload"
assert rows[0]["status"] == "pending"
def test_turning_the_allowlist_off_clears_it(self, viewer, tmp_path):
viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "#west", "allow_global": False})
viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": False, "scopes": "", "allow_global": False})
assert _written(tmp_path).get("Channels", "flood_scopes") == ""
def test_a_star_typed_into_the_list_folds_into_the_flag(self, viewer, tmp_path):
body = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "#west, *", "allow_global": False,
}).get_json()
assert body["settings"]["allow_global"] is True
assert body["settings"]["scopes"] == ["#west"]
assert _written(tmp_path).get("Channels", "flood_scopes") == "#west, *"
def test_scopes_accepts_a_list(self, viewer, tmp_path):
viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": ["west", "#west", "east"]})
assert _written(tmp_path).get("Channels", "flood_scopes") == "#west, #east"
def test_a_global_override_is_stored_as_empty(self, viewer, tmp_path):
"""'*' and '' send identically, but a non-empty override makes
send_channel_message log 'was not applied' on every global send."""
viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": False, "outgoing_override": "*"})
assert _written(tmp_path).get("Channels", "outgoing_flood_scope_override") == ""
def test_an_empty_allowlist_is_refused(self, viewer):
resp = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "", "allow_global": False})
assert resp.status_code == 400
assert "at least one" in resp.get_json()["error"]
def test_a_percent_is_refused_and_never_written(self, viewer, tmp_path):
resp = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "100%west"})
assert resp.status_code == 400
assert "%" in resp.get_json()["error"]
assert "100%" not in (tmp_path / "config.ini").read_text(encoding="utf-8")
# And the file configparser reads back must still be intact.
list(_written(tmp_path).items("Channels"))
def test_a_newline_cannot_inject_config(self, viewer, tmp_path):
"""A newline would end the key's line and re-parse the rest as INI."""
resp = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True,
"scopes": ["west\nrespond_to_dms = false"],
})
assert resp.status_code == 400
text = (tmp_path / "config.ini").read_text(encoding="utf-8")
assert "respond_to_dms" not in text
def test_a_bad_outgoing_override_is_refused(self, viewer):
resp = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": False, "outgoing_override": "we%st"})
assert resp.status_code == 400
def test_turning_the_allowlist_off_does_not_hide_a_bot_section_entry(self, viewer, tmp_path):
"""An empty [Channels] flood_scopes hands the allowlist back to [Bot],
so the page has to keep reporting it rather than showing the "off" the
operator asked for."""
config_path = tmp_path / "config.ini"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"[Bot]", "[Bot]\nflood_scopes = #west\n"),
encoding="utf-8",
)
body = viewer.app.test_client().post(
"/api/region-scopes", json={"allowlist_enabled": False}).get_json()
assert _written(tmp_path).get("Channels", "flood_scopes") == ""
assert body["settings"]["legacy_section"] == "Bot"
assert body["settings"]["allowlist_active"] is True
def test_saved_settings_are_read_back_by_the_get(self, viewer):
viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "west", "allow_global": True,
"outgoing_override": "#west"})
data = viewer.app.test_client().get("/api/region-scopes").get_json()
assert data["scopes"] == ["#west"]
assert data["allow_global"] is True
assert data["allowlist_active"] is True
assert data["outgoing_override"] == "#west"
def test_the_local_overlay_wins_when_it_owns_the_section(self, viewer, tmp_path):
"""Writing the base copy would be silently overridden at merge time."""
local_dir = tmp_path / "local"
local_dir.mkdir()
(local_dir / "config.ini").write_text(
"[Channels]\nflood_scopes = #old\n", encoding="utf-8")
body = viewer.app.test_client().post("/api/region-scopes", json={
"allowlist_enabled": True, "scopes": "#west"}).get_json()
assert body["settings"]["target"] == "local/config.ini"
local = configparser.ConfigParser()
local.read(local_dir / "config.ini", encoding="utf-8")
assert local.get("Channels", "flood_scopes") == "#west"
base = configparser.ConfigParser()
base.read(tmp_path / "config.ini", encoding="utf-8")
assert not base.has_option("Channels", "flood_scopes")
class TestDeviceDefaultScopeEndpoint:
"""The radio's own default scope is queued through the firmware config
write, beside the path hash mode."""
def _payload(self, viewer, body):
resp = viewer.app.test_client().post(
"/api/radio/firmware/config/write", json=body)
return resp, resp.get_json()
def test_a_scope_is_queued_in_its_canonical_form(self, viewer):
resp, body = self._payload(viewer, {"default_flood_scope": "west"})
assert resp.status_code == 200
rows = viewer.db_manager.execute_query(
"SELECT payload_data FROM channel_operations WHERE id = ?",
(body["operation_id"],))
assert json.loads(rows[0]["payload_data"]) == {"default_flood_scope": "#west"}
def test_a_blank_scope_queues_the_clear(self, viewer):
resp, body = self._payload(viewer, {"default_flood_scope": ""})
assert resp.status_code == 200
rows = viewer.db_manager.execute_query(
"SELECT payload_data FROM channel_operations WHERE id = ?",
(body["operation_id"],))
assert json.loads(rows[0]["payload_data"]) == {"default_flood_scope": ""}
def test_a_global_marker_is_the_clear_too(self, viewer):
resp, body = self._payload(viewer, {"default_flood_scope": "*"})
rows = viewer.db_manager.execute_query(
"SELECT payload_data FROM channel_operations WHERE id = ?",
(body["operation_id"],))
assert json.loads(rows[0]["payload_data"])["default_flood_scope"] == ""
def test_a_name_the_radio_cannot_store_is_refused(self, viewer):
resp, body = self._payload(viewer, {"default_flood_scope": "#" + "w" * 30})
assert resp.status_code == 400
assert "30" in body["error"]
def test_a_non_ascii_name_is_refused(self, viewer):
resp, body = self._payload(viewer, {"default_flood_scope": "süd"})
assert resp.status_code == 400
assert "ASCII" in body["error"]
def test_the_path_hash_mode_still_works_alone(self, viewer):
resp, body = self._payload(viewer, {"path_hash_mode": 2})
assert resp.status_code == 200
rows = viewer.db_manager.execute_query(
"SELECT payload_data FROM channel_operations WHERE id = ?",
(body["operation_id"],))
assert json.loads(rows[0]["payload_data"]) == {"path_hash_mode": 2}
def test_an_empty_body_is_still_rejected(self, viewer):
resp, body = self._payload(viewer, {"nonsense": 1})
assert resp.status_code == 400
assert "default_flood_scope" in body["error"]
class TestRadioPage:
def test_the_card_renders_on_the_radio_page(self, viewer):
body = viewer.app.test_client().get("/radio").data.decode()
assert "Region Scopes" in body
assert 'id="saveRegionScopesBtn"' in body
def test_the_device_default_scope_field_sits_in_node_settings(self, viewer):
body = viewer.app.test_client().get("/radio").data.decode()
assert 'id="nodeDefaultScope"' in body
# Next to the path hash size, which is the other firmware-stored
# setting on that card.
assert body.index('id="nodePathHashMode"') < body.index('id="nodeDefaultScope"')
assert body.index('id="nodeDefaultScope"') < body.index('id="nodeName"')
+245
View File
@@ -0,0 +1,245 @@
"""Tests for the /region-warnings page and its API."""
from __future__ import annotations
import configparser
from datetime import datetime
from unittest.mock import patch
import pytest
from modules import region_warning
@pytest.fixture
def viewer(tmp_path):
"""BotDataViewer against a temp config + empty DB (migrations create tables)."""
from modules.web_viewer.app import BotDataViewer
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "db_path", str(tmp_path / "meshcore_bot.db"))
config.set("Bot", "bot_name", "TestBot")
config.add_section("Web_Viewer")
for key, value in [
("host", "127.0.0.1"), ("port", "8080"), ("enabled", "false"),
("auto_start", "false"), ("debug", "false"),
("cors_allowed_origins", "*"), ("web_viewer_password", ""),
]:
config.set("Web_Viewer", key, value)
config_path = str(tmp_path / "config.ini")
with open(config_path, "w") as handle:
config.write(handle)
with patch.object(BotDataViewer, "_start_database_polling"), \
patch.object(BotDataViewer, "_start_log_tailing"), \
patch.object(BotDataViewer, "_start_cleanup_scheduler"), \
patch.object(BotDataViewer, "_start_dashboard_refresher"), \
patch.object(BotDataViewer, "_setup_socketio_handlers"), \
patch("modules.web_viewer.app.RepeaterManager"):
instance = BotDataViewer(
db_path=str(tmp_path / "meshcore_bot.db"), config_path=config_path)
instance.app.testing = True
return instance
def _seed_tally(viewer, channel="#general", scoped=30, globally=10, unknown=5):
today = datetime.now().date().isoformat()
with viewer.db_manager.connection() as conn:
conn.execute(
"INSERT INTO region_scope_daily "
"(date, channel, scoped_count, global_count, unknown_count) "
"VALUES (?, ?, ?, ?, ?)",
(today, channel, scoped, globally, unknown),
)
conn.commit()
class TestRegionWarningsPage:
def test_page_renders(self, viewer):
resp = viewer.app.test_client().get("/region-warnings")
assert resp.status_code == 200
assert b"Region Warnings" in resp.data
def test_page_script_carries_a_csp_nonce(self, viewer):
"""The endpoint is in the nonce-hardened set, so its script must be nonced."""
resp = viewer.app.test_client().get("/region-warnings")
body = resp.data.decode()
assert "<script nonce=" in body
assert "unsafe-inline" not in resp.headers["Content-Security-Policy"].split("style-src")[0]
class TestRegionWarningsApi:
def test_defaults_when_section_absent(self, viewer):
data = viewer.app.test_client().get("/api/region-warnings").get_json()
assert data["settings"]["enabled"] == "false"
assert data["settings"]["dry_run"] == "true"
assert data["traffic"]["channels"] == []
assert data["events"] == []
def test_limits_account_for_the_bot_name(self, viewer):
data = viewer.app.test_client().get("/api/region-warnings").get_json()
assert data["limits"]["dm"] == region_warning.DM_BODY_LIMIT
assert data["limits"]["channel"] == max(130, 160 - len("TestBot") - 2)
def test_traffic_reflects_seeded_tallies(self, viewer):
_seed_tally(viewer)
data = viewer.app.test_client().get("/api/region-warnings").get_json()
channel = data["traffic"]["channels"][0]
assert channel["channel"] == "#general"
assert channel["unscoped_pct"] == 25.0
assert len(data["series"]) == 14
def test_window_is_clamped(self, viewer):
data = viewer.app.test_client().get("/api/region-warnings?days=9999").get_json()
assert data["traffic"]["days"] == 90
def test_bad_window_falls_back(self, viewer):
data = viewer.app.test_client().get("/api/region-warnings?days=abc").get_json()
assert data["traffic"]["days"] == 14
class TestRegionWarningsSave:
def test_save_writes_config_and_queues_reload(self, viewer, tmp_path):
resp = viewer.app.test_client().post("/api/region-warnings/settings", json={
"enabled": True,
"dry_run": True,
"delivery": "channel",
"channels": "#General, general, weather",
"message": "Set a region, {sender}",
"min_unscoped_messages": 5,
"per_sender_cooldown_hours": 24,
"mesh_cooldown_minutes": 15,
"max_warnings_per_day": 3,
"track_traffic": False,
})
assert resp.status_code == 200
body = resp.get_json()
assert body["success"] is True
assert body["reload_queued"] is True
# Duplicates collapse and the leading # is dropped.
assert body["settings"]["channels"] == "general, weather"
text = (tmp_path / "config.ini").read_text(encoding="utf-8")
assert "[Region_Warnings]" in text
written = configparser.ConfigParser()
written.read(tmp_path / "config.ini", encoding="utf-8")
settings = region_warning.load_settings(written)
assert settings.enabled is True
assert settings.delivery == "channel"
assert settings.channels == ("general", "weather")
assert settings.min_unscoped_messages == 5
assert settings.track_traffic is False
def test_rejects_unknown_delivery(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings", json={"delivery": "smoke signal"})
assert resp.status_code == 400
assert "delivery" in resp.get_json()["error"]
def test_rejects_out_of_range_number(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "min_unscoped_messages": 0})
assert resp.status_code == 400
def test_rejects_non_numeric(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "mesh_cooldown_minutes": "soon"})
assert resp.status_code == 400
def test_rejects_multiline_message(self, viewer):
"""A newline in an INI value would break the file open at the next read."""
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "message": "line one\nline two"})
assert resp.status_code == 400
def test_rejects_percent_in_the_message(self, viewer):
"""A bare % makes configparser raise on every later read of the section,
which would reject the bot's hot reloads until the file was hand-edited."""
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "message": "100% of the mesh, set a region"})
assert resp.status_code == 400
assert "%" in resp.get_json()["error"]
def test_percent_is_never_written_to_config(self, viewer, tmp_path):
viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "message": "50% done"})
text = (tmp_path / "config.ini").read_text(encoding="utf-8")
assert "50%" not in text
# And the file configparser reads back must still be intact.
written = configparser.ConfigParser()
written.read(tmp_path / "config.ini", encoding="utf-8")
if written.has_section("Region_Warnings"):
list(written.items("Region_Warnings"))
def test_rejects_an_overlong_message(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "message": "x" * 501})
assert resp.status_code == 400
def test_budget_separates_delivered_from_attempted(self, viewer):
with viewer.db_manager.connection() as conn:
for action in ("sent", "failed", "failed"):
conn.execute(
"INSERT INTO region_warning_events "
"(created_at, sender_id, channel, delivery, action) VALUES (?, ?, ?, ?, ?)",
(datetime.now().isoformat(sep=" ", timespec="seconds"),
"Ann", "#gen", "dm", action),
)
conn.commit()
budget = viewer.app.test_client().get("/api/region-warnings").get_json()["budget"]
assert budget["used_today"] == 3
assert budget["delivered_today"] == 1
assert budget["failed_today"] == 2
assert budget["previewed_today"] == 0
assert budget["withheld_today"] == 0
def test_budget_reports_warnings_withheld_for_want_of_a_contact(self, viewer):
"""The viewer reads the bot's counter, so an empty log can explain itself."""
import json
viewer.db_manager.set_metadata(
region_warning.WITHHELD_METADATA_KEY,
json.dumps({"date": datetime.now().date().isoformat(), "count": 7}),
)
budget = viewer.app.test_client().get("/api/region-warnings").get_json()["budget"]
assert budget["withheld_today"] == 7
def test_a_stale_withheld_counter_is_not_reported_as_today(self, viewer):
import json
viewer.db_manager.set_metadata(
region_warning.WITHHELD_METADATA_KEY,
json.dumps({"date": "2020-01-01", "count": 99}),
)
budget = viewer.app.test_client().get("/api/region-warnings").get_json()["budget"]
assert budget["withheld_today"] == 0
def test_empty_message_falls_back_to_the_default(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings", json={"delivery": "dm", "message": " "})
assert resp.status_code == 200
assert resp.get_json()["settings"]["message"] == region_warning.DEFAULT_MESSAGE
def test_channels_accepts_a_list(self, viewer):
resp = viewer.app.test_client().post(
"/api/region-warnings/settings",
json={"delivery": "dm", "channels": ["#a", "b"]})
assert resp.get_json()["settings"]["channels"] == "a, b"
def test_saved_settings_are_read_back_by_the_get(self, viewer):
viewer.app.test_client().post("/api/region-warnings/settings", json={
"enabled": True, "dry_run": False, "delivery": "dm",
"max_warnings_per_day": 2,
})
data = viewer.app.test_client().get("/api/region-warnings").get_json()
assert data["settings"]["enabled"] == "true"
assert data["settings"]["dry_run"] == "false"
assert data["budget"]["cap"] == 2
+200
View File
@@ -0,0 +1,200 @@
"""The radio's own default flood scope: reading it, writing it, clearing it.
The firmware keeps a default region in NodePrefs (``default_scope_name`` plus
``default_scope_key``) and uses it for any send the app has not scoped itself.
These cover the scheduler operations behind the Radio page's Default Region
Scope field.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from meshcore.events import EventType
from meshcore.packets import CommandType
from modules import flood_scope
from modules.scheduler import MessageScheduler
def _scheduler(meshcore):
bot = MagicMock()
bot.meshcore = meshcore
scheduler = object.__new__(MessageScheduler)
scheduler.bot = bot
scheduler.logger = Mock()
return scheduler
def _meshcore(**command_attrs):
meshcore = MagicMock()
meshcore.is_connected = True
meshcore.commands = MagicMock()
for name, value in command_attrs.items():
setattr(meshcore.commands, name, value)
return meshcore
def _event(event_type, payload=None):
event = MagicMock()
event.type = event_type
event.payload = payload
return event
class TestReadDefaultScope:
@pytest.mark.asyncio
async def test_a_configured_scope_is_reported_with_its_key(self):
key = flood_scope.scope_key_hex("#west")
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": key},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result == {
"default_scope_name": "#west",
"default_scope_key": key,
"default_scope_supported": True,
"default_scope_key_matches": True,
}
@pytest.mark.asyncio
async def test_a_build_flag_default_stored_without_its_hash_still_matches(self):
"""The firmware's DEFAULT_FLOOD_SCOPE_NAME build flag stores the bare
name but hashes the '#' form, so comparing raw would report a mismatch
on a radio that is perfectly consistent."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "west", "scope_key": flood_scope.scope_key_hex("#west")},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_key_matches"] is True
@pytest.mark.asyncio
async def test_a_key_that_is_not_the_name_s_hash_is_flagged(self):
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": "00" * 16},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_key_matches"] is False
@pytest.mark.asyncio
async def test_the_one_byte_sentinel_reads_as_cleared_not_unsupported(self):
"""A radio with no default scope answers with the response code alone,
which the library turns into an empty payload."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(EventType.DEFAULT_FLOOD_SCOPE, {})))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_supported"] is True
assert result["default_scope_name"] == ""
assert "default_scope_key_matches" not in result
@pytest.mark.asyncio
async def test_an_error_reply_reports_nothing_rather_than_cleared(self):
"""Firmware without the command must not be shown as 'no scope set'."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(EventType.ERROR)))
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_a_raised_error_never_costs_the_caller_the_path_hash_read(self):
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
side_effect=TimeoutError("no answer")))
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_a_library_without_the_command_reports_nothing(self):
meshcore = _meshcore()
del meshcore.commands.get_default_flood_scope
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_the_firmware_read_op_carries_the_scope_alongside_the_mode(self):
meshcore = _meshcore(
get_path_hash_mode=AsyncMock(return_value=1),
get_default_flood_scope=AsyncMock(return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": flood_scope.scope_key_hex("#west")},
)),
)
ok, data = await _scheduler(meshcore)._firmware_read_op()
assert ok is True
assert data["path_hash_mode"] == 1
assert data["default_scope_name"] == "#west"
class TestWriteDefaultScope:
@pytest.mark.asyncio
async def test_setting_a_scope_goes_through_the_library(self):
setter = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(set_default_flood_scope=setter)
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert (ok, error) == (True, "")
setter.assert_awaited_once_with("#west")
@pytest.mark.asyncio
async def test_clearing_sends_the_bare_frame_the_firmware_documents(self):
"""set_default_flood_scope cannot clear: None raises on len(None), ''
earns ILLEGAL_ARG, and '*' only works via a padding off-by-one."""
setter = AsyncMock(return_value=_event(EventType.OK))
send = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(set_default_flood_scope=setter, send=send)
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "")
assert (ok, error) == (True, "")
setter.assert_not_awaited()
frame = send.await_args.args[0]
assert bytes(frame) == bytes([CommandType.SET_DEFAULT_FLOOD_SCOPE.value])
@pytest.mark.asyncio
async def test_a_rejected_write_is_reported_with_the_radio_s_answer(self):
meshcore = _meshcore(set_default_flood_scope=AsyncMock(
return_value=_event(EventType.ERROR)))
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert ok is False
assert "set_default_flood_scope(#west) failed" in error
@pytest.mark.asyncio
async def test_a_library_without_the_command_fails_loudly(self):
meshcore = _meshcore()
del meshcore.commands.set_default_flood_scope
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert ok is False
assert "meshcore library" in error
@pytest.mark.asyncio
async def test_the_firmware_write_op_carries_both_settings(self):
setter = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(
set_path_hash_mode=AsyncMock(return_value=_event(EventType.OK)),
set_default_flood_scope=setter,
)
ok, data = await _scheduler(meshcore)._firmware_write_op(
{"path_hash_mode": 1, "default_flood_scope": "#west"})
assert ok is True
assert data["results"] == {"path_hash_mode": True, "default_flood_scope": True}
class TestDeviceScopeNameRules:
def test_a_name_is_normalized_like_every_other_scope(self):
assert flood_scope.validate_device_scope_name("west") == "#west"
def test_a_name_too_long_for_the_radio_s_field_is_refused(self):
"""NodePrefs.default_scope_name is 31 bytes and the firmware demands
room for the terminator, so 30 characters is the ceiling."""
assert flood_scope.validate_device_scope_name("#" + "w" * 29) == "#" + "w" * 29
with pytest.raises(ValueError, match="30"):
flood_scope.validate_device_scope_name("#" + "w" * 30)
def test_a_non_ascii_name_is_refused(self):
"""The library pads the name frame by character count while encoding
UTF-8, so a multi-byte character displaces the transport key."""
with pytest.raises(ValueError, match="ASCII"):
flood_scope.validate_device_scope_name("süd")
def test_global_markers_pass_through_as_the_cleared_state(self):
assert flood_scope.validate_device_scope_name("*") == "*"
+35
View File
@@ -183,6 +183,41 @@ async def test_send_channel_message_applies_channel_scope():
assert set_flood_scope.await_args_list[0].args[0] == "#sea"
@pytest.mark.asyncio
async def test_send_channel_message_reads_lowercase_none_as_global():
"""Every other path normalizes "none" to the global marker. The send path
used to compare the raw value, so this spelling became the region "#none"
and set_flood_scope was called with it."""
config = _make_config(outgoing_flood_scope_override="none")
bot = MagicMock()
bot.config = config
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_manager.get_channel_number.return_value = 1
set_flood_scope = AsyncMock(return_value=MagicMock(type="OK"))
send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={}))
bot.meshcore = MagicMock()
bot.meshcore.commands.set_flood_scope = set_flood_scope
bot.meshcore.commands.send_chan_msg = send_chan_msg
cm = object.__new__(CommandManager)
cm.bot = bot
cm.logger = bot.logger
cm.flood_scope_allow_global = False
cm.flood_scope_keys = {}
cm._check_rate_limits = AsyncMock(return_value=(True, None))
cm._handle_send_result = MagicMock(return_value=True)
cm._is_no_event_received = MagicMock(return_value=False)
await cm.send_channel_message("general", "hi", scope=None)
set_flood_scope.assert_not_awaited()
send_chan_msg.assert_awaited()
# ---------------------------------------------------------------------------
# Shared helpers for Path-F and Path-G tests
# ---------------------------------------------------------------------------
+789
View File
@@ -0,0 +1,789 @@
"""Unit tests for regional flood-scope classification and the warning monitor."""
import configparser
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from modules import region_warning
from modules.db_migrations import MigrationRunner
from modules.enums import PayloadType, RouteType
from modules.message_handler import (
CHANNEL_SENDER_FALLBACK,
RF_MATCH_KEY,
MessageHandler,
)
from modules.region_warning import (
ACTION_DRY_RUN,
ACTION_FAILED,
ACTION_SENT,
VERDICT_GLOBAL,
VERDICT_SCOPED,
VERDICT_UNKNOWN,
RegionWarningMonitor,
RegionWarningSettings,
)
GRP_TXT = int(PayloadType.GRP_TXT.value)
# ---------------------------------------------------------------------------
# Fixtures / builders
# ---------------------------------------------------------------------------
def _handler() -> MessageHandler:
"""A MessageHandler with only the attributes the classifier touches."""
handler = object.__new__(MessageHandler)
handler.bot = MagicMock()
handler.logger = Mock()
return handler
def _rf(correlated: bool = True, **fields):
row = {RF_MATCH_KEY: "exact" if correlated else "fallback"}
row.update(fields)
return row
def _scope_eligible_rf(correlated: bool = True):
return _rf(
correlated,
route_type_int=int(RouteType.TRANSPORT_FLOOD.value),
transport_code1=4242,
payload_type_int=GRP_TXT,
scope_payload_hex="aabbcc",
)
class _FakeDBManager:
"""A real SQLite database on a shared in-memory connection."""
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
MigrationRunner(self._conn, Mock()).run()
@contextmanager
def connection(self):
yield self._conn
def execute_query(self, query, params=()):
cursor = self._conn.cursor()
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
def set_metadata(self, key, value):
self._conn.execute(
"INSERT INTO bot_metadata (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
self._conn.commit()
def get_metadata(self, key):
row = self._conn.execute(
"SELECT value FROM bot_metadata WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
def _config(**values) -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "bot_name", "TestBot")
config.add_section(region_warning.CONFIG_SECTION)
for key, value in values.items():
config.set(region_warning.CONFIG_SECTION, key, str(value))
return config
def _monitor(db=None, **settings_values) -> RegionWarningMonitor:
bot = MagicMock()
bot.logger = Mock()
bot.config = _config(**settings_values)
bot.db_manager = db if db is not None else _FakeDBManager()
bot.channel_responses_enabled = True
bot.meshcore.self_info = {"public_key": "bbbbcccc" * 8}
# CHANNEL_MSG_RECV carries no pubkey, so every sender below is a bare name.
bot.meshcore.contacts = {}
bot.meshcore.get_contact_by_name = lambda name: {"adv_name": name}
bot.command_manager = MagicMock()
bot.command_manager.is_user_banned.return_value = False
bot.command_manager.send_dm = AsyncMock(return_value=True)
bot.command_manager.send_channel_message = AsyncMock(return_value=True)
bot.command_manager.get_max_message_length.return_value = 130
return RegionWarningMonitor(bot)
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
class TestClassifyFloodScope:
def _classify(self, **kwargs):
args = {
"reply_scope": None,
"recent_rf_data": None,
"packet_info": None,
"scope_rf_data": None,
"scope_packet_info": None,
}
args.update(kwargs)
return _handler()._classify_channel_flood_scope(**args)
def test_matched_reply_scope_is_scoped(self):
assert self._classify(reply_scope="#west") == VERDICT_SCOPED
def test_correlated_scope_eligible_packet_is_scoped(self):
"""A transport code means a region was set, even one we have no key for."""
assert self._classify(scope_rf_data=_scope_eligible_rf()) == VERDICT_SCOPED
def test_correlated_transport_flood_is_scoped(self):
assert self._classify(
recent_rf_data=_rf(route_type_int=int(RouteType.TRANSPORT_FLOOD.value)),
scope_rf_data=_scope_eligible_rf(),
) == VERDICT_SCOPED
def test_correlated_plain_flood_is_global(self):
assert self._classify(
recent_rf_data=_rf(route_type_int=int(RouteType.FLOOD.value)),
) == VERDICT_GLOBAL
def test_uncorrelated_row_is_never_global(self):
"""An argument from absence must not spend airtime accusing anyone.
flood_scopes accepts "no scope-eligible packet in the window" as proof
of an unscoped FLOOD, because the cost of being wrong there is one extra
reply. Here it would be an unsolicited warning, so the verdict needs RF
correlated to this message.
"""
assert self._classify(
recent_rf_data=_rf(False, route_type_int=int(RouteType.FLOOD.value)),
scope_rf_data=None,
) == VERDICT_UNKNOWN
def test_uncorrelated_transport_flood_row_is_never_global(self):
"""A row that says TC_FLOOD must not come back as 'no region code'."""
assert self._classify(
recent_rf_data=_rf(False, route_type_int=int(RouteType.TRANSPORT_FLOOD.value)),
scope_rf_data=None,
) == VERDICT_UNKNOWN
def test_payload_verified_correlation_counts_as_correlated(self):
"""The CHAN payload match (#255) is how a channel message correlates at all."""
row = {RF_MATCH_KEY: "payload", "route_type_int": int(RouteType.FLOOD.value)}
assert self._classify(recent_rf_data=row, scope_rf_data=None) == VERDICT_GLOBAL
def test_uncorrelated_with_scoped_traffic_in_window_is_unknown(self):
assert self._classify(
recent_rf_data=_rf(False, route_type_int=int(RouteType.FLOOD.value)),
scope_rf_data=_scope_eligible_rf(correlated=False),
) == VERDICT_UNKNOWN
def test_no_rf_data_is_unknown(self):
assert self._classify() == VERDICT_UNKNOWN
def test_uncorrelated_scope_row_never_proves_scoped(self):
"""An uncorrelated scope row describes some other packet, so it decides nothing."""
assert self._classify(
scope_rf_data=_scope_eligible_rf(correlated=False),
) == VERDICT_UNKNOWN
def test_decoded_packet_overrides_stale_cached_route_type(self):
assert self._classify(
recent_rf_data=_rf(route_type_int=int(RouteType.FLOOD.value)),
packet_info={"route_type": RouteType.TRANSPORT_FLOOD},
scope_rf_data=None,
) == VERDICT_SCOPED
@pytest.mark.asyncio
class TestObservationHook:
"""The MessageHandler side of the hook, independent of the monitor."""
def _hooked_handler(self):
handler = _handler()
handler.bot.region_warning_monitor = MagicMock()
handler.bot.region_warning_monitor.observe = AsyncMock()
handler.bot.connection_time = None
return handler
async def _observe(self, handler, **overrides):
args = {
"sender_id": "Ann",
"sender_pubkey": "ab",
"channel": "#gen",
"sender_timestamp": 0,
"reply_scope": None,
"recent_rf_data": None,
"packet_info": None,
"scope_rf_data": None,
"scope_packet_info": None,
}
args.update(overrides)
await handler._observe_flood_scope(**args)
async def test_verdict_is_forwarded_to_the_monitor(self):
handler = self._hooked_handler()
await self._observe(
handler, recent_rf_data=_rf(route_type_int=int(RouteType.FLOOD.value)))
kwargs = handler.bot.region_warning_monitor.observe.await_args.kwargs
assert kwargs["verdict"] == VERDICT_GLOBAL
assert kwargs["sender_id"] == "Ann"
async def test_no_monitor_is_a_noop(self):
handler = self._hooked_handler()
handler.bot.region_warning_monitor = None
await self._observe(handler)
async def test_old_cached_message_is_skipped(self):
"""A reconnect replays cached traffic; counting it would distort everything."""
handler = self._hooked_handler()
handler.bot.connection_time = 2_000
await self._observe(handler, sender_timestamp=1_000)
handler.bot.region_warning_monitor.observe.assert_not_awaited()
async def test_monitor_failure_does_not_escape_to_the_message_path(self):
handler = self._hooked_handler()
handler.bot.region_warning_monitor.observe = AsyncMock(
side_effect=RuntimeError("boom"))
await self._observe(handler)
handler.logger.exception.assert_called()
async def test_unattributable_sender_is_passed_through_as_none(self):
handler = self._hooked_handler()
await self._observe(handler, sender_id=None)
kwargs = handler.bot.region_warning_monitor.observe.await_args.kwargs
assert kwargs["sender_id"] is None
def test_channel_sender_fallback_is_the_handler_literal():
"""The hook drops this name; if the handler renames it, the guard silently dies."""
assert CHANNEL_SENDER_FALLBACK == "Channel User"
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
class TestLoadSettings:
def test_defaults_when_section_absent(self):
settings = region_warning.load_settings(configparser.ConfigParser())
assert settings.enabled is False
assert settings.dry_run is True
assert settings.delivery == region_warning.DELIVERY_DM
assert settings.track_traffic is True
def test_channels_normalized(self):
settings = region_warning.load_settings(_config(channels="#General, public ,"))
assert settings.channels == ("general", "public")
def test_unknown_delivery_falls_back_to_dm(self):
settings = region_warning.load_settings(_config(delivery="carrier pigeon"))
assert settings.delivery == region_warning.DELIVERY_DM
def test_garbage_number_falls_back_to_default(self):
settings = region_warning.load_settings(_config(max_warnings_per_day="soon"))
assert settings.max_warnings_per_day == 6
def test_negative_value_falls_back_rather_than_clamping(self):
"""Clamping a typo of -1 to 0 would silently mean 'unlimited' on the cap."""
settings = region_warning.load_settings(_config(mesh_cooldown_minutes="-5"))
assert settings.mesh_cooldown_minutes == 30.0
def test_negative_daily_cap_does_not_become_unlimited(self):
settings = region_warning.load_settings(_config(max_warnings_per_day="-1"))
assert settings.max_warnings_per_day == 6
def test_bare_percent_in_the_message_is_read_not_swallowed(self):
"""A hand-edited bare % must yield the operator's wording, not the default.
Built with read_string rather than config.set: set() rejects a bare %
up front, so it cannot reproduce the value that broke the reload. Read
without raw=True, interpolation raises, _get swallows it, and the bot
transmits DEFAULT_MESSAGE instead of what the file says.
"""
config = configparser.ConfigParser()
config.read_string(
f"[{region_warning.CONFIG_SECTION}]\nmessage = 100% of the mesh, really\n"
)
settings = region_warning.load_settings(config)
assert settings.message == "100% of the mesh, really"
assert settings.message != region_warning.DEFAULT_MESSAGE
def test_empty_allowlist_monitors_every_channel(self):
assert RegionWarningSettings().monitors_channel("#anything") is True
def test_allowlist_matches_regardless_of_hash_and_case(self):
settings = RegionWarningSettings(channels=("general",))
assert settings.monitors_channel("#General") is True
assert settings.monitors_channel("weather") is False
def test_round_trip_through_config_values(self):
original = RegionWarningSettings(
enabled=True, dry_run=False, delivery="channel",
channels=("general",), message="hi {sender}",
min_unscoped_messages=2, per_sender_cooldown_hours=12.5,
mesh_cooldown_minutes=0, max_warnings_per_day=0, track_traffic=False,
)
values = region_warning.settings_to_config_values(original)
assert region_warning.load_settings(_config(**values)) == original
class TestMessageRendering:
def test_placeholders_substituted(self):
text = region_warning.render_message("hey {sender} on {channel}", "Ann", "#gen")
assert text == "hey Ann on #gen"
def test_unknown_placeholder_left_alone(self):
assert region_warning.render_message("{nope}", "Ann", "#gen") == "{nope}"
def test_truncation_does_not_split_a_character(self):
text = "aaaééé" # each é is two UTF-8 bytes
trimmed = region_warning.truncate_to_bytes(text, 6)
assert trimmed == "aaaé"
assert len(trimmed.encode("utf-8")) <= 6
def test_short_text_unchanged(self):
assert region_warning.truncate_to_bytes("hello", 158) == "hello"
# ---------------------------------------------------------------------------
# Monitor behavior
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestObservation:
async def test_tallies_each_verdict(self):
monitor = _monitor()
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
await monitor.observe(
verdict=VERDICT_SCOPED, sender_id="Bob", sender_pubkey="cd", channel="#gen")
rows = monitor.bot.db_manager.execute_query(
"SELECT scoped_count, global_count FROM region_scope_daily")
assert rows == [{"scoped_count": 1, "global_count": 1}]
async def test_track_traffic_off_writes_no_tally(self):
monitor = _monitor(track_traffic="false")
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
assert monitor.bot.db_manager.execute_query(
"SELECT * FROM region_scope_daily") == []
async def test_disabled_never_sends(self):
monitor = _monitor(min_unscoped_messages=1)
for _ in range(5):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
monitor.bot.command_manager.send_dm.assert_not_called()
assert monitor.bot.db_manager.execute_query(
"SELECT * FROM region_warning_events") == []
async def test_unknown_verdict_never_warns(self):
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
for _ in range(5):
await monitor.observe(
verdict=VERDICT_UNKNOWN, sender_id="Ann", sender_pubkey="ab", channel="#gen")
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_unattributable_sender_is_counted_but_never_warned(self):
"""A channel message with no "Name: " prefix has no real sender."""
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
for _ in range(5):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id=None, sender_pubkey="", channel="#gen")
monitor.bot.command_manager.send_dm.assert_not_called()
rows = monitor.bot.db_manager.execute_query(
"SELECT global_count FROM region_scope_daily")
assert rows[0]["global_count"] == 5
async def test_bookkeeping_failure_does_not_raise(self):
monitor = _monitor()
monitor.bot.db_manager = None
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
@pytest.mark.asyncio
class TestWarningGates:
async def _flood(self, monitor, count, sender="Ann", channel="#gen"):
# sender_pubkey="" matches handle_channel_message: CHANNEL_MSG_RECV has none.
for _ in range(count):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id=sender, sender_pubkey="", channel=channel)
async def test_warns_after_min_unscoped_messages(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=3, mesh_cooldown_minutes=0)
await self._flood(monitor, 2)
monitor.bot.command_manager.send_dm.assert_not_called()
await self._flood(monitor, 1)
monitor.bot.command_manager.send_dm.assert_called_once()
recipient, body = monitor.bot.command_manager.send_dm.call_args[0]
assert recipient == "Ann"
assert "region code" in body
async def test_scoped_message_resets_the_run(self):
"""Once a sender sets a region, their earlier unscoped run is stale."""
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=3, mesh_cooldown_minutes=0)
await self._flood(monitor, 2)
await monitor.observe(
verdict=VERDICT_SCOPED, sender_id="Ann", sender_pubkey="ab", channel="#gen")
await self._flood(monitor, 2)
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_channel_allowlist_excludes_other_channels(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, channels="general")
await self._flood(monitor, 3, channel="#weather")
monitor.bot.command_manager.send_dm.assert_not_called()
await self._flood(monitor, 1, channel="#general")
monitor.bot.command_manager.send_dm.assert_called_once()
async def test_banned_sender_is_never_warned(self):
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
monitor.bot.command_manager.is_user_banned.return_value = True
await self._flood(monitor, 3)
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_bot_never_warns_itself(self):
"""The channel path has no pubkey, so the name is what has to catch it."""
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
await self._flood(monitor, 3, sender="TestBot")
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_bot_is_recognized_by_pubkey_when_one_is_available(self):
"""Not reachable from CHANNEL_MSG_RECV today; kept for paths that do carry one."""
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
for _ in range(3):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Renamed Bot",
sender_pubkey="bbbbcccc", channel="#gen")
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_withheld_warnings_are_counted_so_the_page_can_say_so(self):
"""An empty log otherwise looks the same as a mesh with nothing to report."""
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
monitor.bot.meshcore.get_contact_by_name = lambda name: None
await self._flood(monitor, 4, sender="Stranger")
budget = region_warning.warning_budget(
monitor.bot.db_manager, monitor.settings, monitor.bot.config)
assert budget["withheld_today"] == 4
assert budget["used_today"] == 0
async def test_withheld_counter_does_not_run_for_channel_delivery(self):
monitor = _monitor(
enabled="true", dry_run="false", delivery="channel",
min_unscoped_messages=1, mesh_cooldown_minutes=0)
monitor.bot.meshcore.get_contact_by_name = lambda name: None
await self._flood(monitor, 1, sender="Passer By")
budget = region_warning.warning_budget(
monitor.bot.db_manager, monitor.settings, monitor.bot.config)
assert budget["withheld_today"] == 0
async def test_a_name_with_no_contact_behind_it_is_not_dmed(self):
"""The sender is a display name off the wire, forgeable by anyone."""
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
monitor.bot.meshcore.get_contact_by_name = lambda name: None
await self._flood(monitor, 3, sender="Victim Node")
monitor.bot.command_manager.send_dm.assert_not_called()
assert monitor.bot.db_manager.execute_query(
"SELECT * FROM region_warning_events") == []
async def test_channel_delivery_does_not_require_a_contact(self):
"""A channel reply addresses the channel, so there is no contact to resolve."""
monitor = _monitor(
enabled="true", dry_run="false", delivery="channel",
min_unscoped_messages=1, mesh_cooldown_minutes=0)
monitor.bot.meshcore.get_contact_by_name = lambda name: None
await self._flood(monitor, 1, sender="Passer By")
monitor.bot.command_manager.send_channel_message.assert_called_once()
async def test_channelpause_silences_warnings(self):
monitor = _monitor(enabled="true", dry_run="false", min_unscoped_messages=1)
monitor.bot.channel_responses_enabled = False
await self._flood(monitor, 3)
monitor.bot.command_manager.send_dm.assert_not_called()
async def test_daily_cap_counts_failed_attempts(self):
"""A failed send may still have spent airtime, so it spends the cap."""
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0, max_warnings_per_day=2)
monitor.bot.command_manager.send_dm = AsyncMock(side_effect=[False, False, True])
for name in ("Ann", "Bob", "Cat"):
await self._flood(monitor, 1, sender=name)
assert monitor.bot.command_manager.send_dm.call_count == 2
async def test_concurrent_messages_cannot_both_pass_a_cap_of_one(self):
"""The slot is reserved before the send awaits, so a second message sees it."""
import asyncio
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0, max_warnings_per_day=1)
async def slow_send(*_args, **_kwargs):
await asyncio.sleep(0)
await asyncio.sleep(0)
return True
monitor.bot.command_manager.send_dm = AsyncMock(side_effect=slow_send)
await asyncio.gather(
monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen"),
monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Bob", sender_pubkey="cd", channel="#gen"),
)
assert monitor.bot.command_manager.send_dm.call_count == 1
async def test_failed_send_row_is_corrected_not_duplicated(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0)
monitor.bot.command_manager.send_dm = AsyncMock(return_value=False)
await self._flood(monitor, 1)
rows = monitor.bot.db_manager.execute_query(
"SELECT action, detail FROM region_warning_events")
assert len(rows) == 1
assert rows[0]["action"] == ACTION_FAILED
assert "failed" in rows[0]["detail"]
async def test_sender_table_stays_bounded(self):
monitor = _monitor(min_unscoped_messages=99)
monitor.MAX_TRACKED_SENDERS = 10
for i in range(60):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id=f"node-{i}",
sender_pubkey="ab", channel="#gen")
assert len(monitor._senders) <= monitor.MAX_TRACKED_SENDERS + 1
async def test_mesh_cooldown_blocks_a_second_sender(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=30, per_sender_cooldown_hours=0)
await self._flood(monitor, 1, sender="Ann")
await self._flood(monitor, 1, sender="Bob")
assert monitor.bot.command_manager.send_dm.call_count == 1
async def test_per_sender_cooldown_blocks_a_repeat(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=168)
await self._flood(monitor, 1, sender="Ann")
await self._flood(monitor, 1, sender="Bob")
await self._flood(monitor, 1, sender="Ann")
senders = [c[0][0] for c in monitor.bot.command_manager.send_dm.call_args_list]
assert senders == ["Ann", "Bob"]
async def test_daily_cap_stops_further_warnings(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0, max_warnings_per_day=2)
for name in ("Ann", "Bob", "Cat", "Dan"):
await self._flood(monitor, 1, sender=name)
assert monitor.bot.command_manager.send_dm.call_count == 2
async def test_cooldown_survives_a_restart(self):
db = _FakeDBManager()
first = _monitor(
db, enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=60, per_sender_cooldown_hours=0)
await self._flood(first, 1, sender="Ann")
assert first.bot.command_manager.send_dm.call_count == 1
second = _monitor(
db, enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=60, per_sender_cooldown_hours=0)
await self._flood(second, 1, sender="Bob")
second.bot.command_manager.send_dm.assert_not_called()
async def test_failed_send_does_not_start_the_mesh_cooldown(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=60, per_sender_cooldown_hours=0)
monitor.bot.command_manager.send_dm = AsyncMock(side_effect=[False, True])
await self._flood(monitor, 1, sender="Ann")
await self._flood(monitor, 1, sender="Bob")
assert monitor.bot.command_manager.send_dm.call_count == 2
actions = [
row["action"] for row in
monitor.bot.db_manager.execute_query(
"SELECT action FROM region_warning_events ORDER BY id")
]
assert actions == [ACTION_FAILED, ACTION_SENT]
async def test_one_failing_sender_cannot_burn_the_cap_every_day(self):
"""The cap counts attempts, so the per-sender cooldown must too.
When only the cap counted failures, a sender the radio could not reach
was retried every min_unscoped_messages messages forever: the day's
whole budget went on one node and nobody was ever warned.
"""
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=168,
max_warnings_per_day=3)
monitor.bot.command_manager.send_dm = AsyncMock(return_value=False)
await self._flood(monitor, 10, sender="Unreachable")
assert monitor.bot.command_manager.send_dm.call_count == 1
# The budget is still there for someone the bot can actually reach.
monitor.bot.command_manager.send_dm = AsyncMock(return_value=True)
await self._flood(monitor, 1, sender="Real Offender")
monitor.bot.command_manager.send_dm.assert_called_once()
async def test_failed_send_still_spends_the_run(self):
"""Otherwise a failing contact is retried on the sender's very next message."""
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=2,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0)
monitor.bot.command_manager.send_dm = AsyncMock(return_value=False)
await self._flood(monitor, 3)
assert monitor.bot.command_manager.send_dm.call_count == 1
@pytest.mark.asyncio
class TestDelivery:
async def test_budget_keeps_previews_apart_from_real_sends(self):
"""A morning of dry run must not read as afternoon transmissions."""
monitor = _monitor(
enabled="true", dry_run="true", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0)
for name in ("Ann", "Bob"):
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id=name, sender_pubkey="", channel="#gen")
budget = region_warning.warning_budget(
monitor.bot.db_manager, monitor.settings, monitor.bot.config)
assert budget["previewed_today"] == 2
assert budget["delivered_today"] == 0
assert budget["used_today"] == 2
async def test_dry_run_records_but_transmits_nothing(self):
monitor = _monitor(
enabled="true", dry_run="true", min_unscoped_messages=1, mesh_cooldown_minutes=0)
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
monitor.bot.command_manager.send_dm.assert_not_called()
monitor.bot.command_manager.send_channel_message.assert_not_called()
rows = monitor.bot.db_manager.execute_query(
"SELECT action FROM region_warning_events")
assert [r["action"] for r in rows] == [ACTION_DRY_RUN]
async def test_dry_run_consumes_the_same_budget_it_previews(self):
monitor = _monitor(
enabled="true", dry_run="true", min_unscoped_messages=1,
mesh_cooldown_minutes=0, per_sender_cooldown_hours=0, max_warnings_per_day=1)
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Bob", sender_pubkey="cd", channel="#gen")
rows = monitor.bot.db_manager.execute_query(
"SELECT sender_id FROM region_warning_events")
assert [r["sender_id"] for r in rows] == ["Ann"]
async def test_channel_delivery_forces_global_scope(self):
"""A scoped reply could never reach someone outside the region."""
monitor = _monitor(
enabled="true", dry_run="false", delivery="channel",
min_unscoped_messages=1, mesh_cooldown_minutes=0)
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
call = monitor.bot.command_manager.send_channel_message.call_args
assert call[0][0] == "#gen"
assert call[1]["scope"] == "*"
async def test_long_message_is_truncated_to_the_dm_budget(self):
monitor = _monitor(
enabled="true", dry_run="false", min_unscoped_messages=1,
mesh_cooldown_minutes=0, message="x" * 400)
await monitor.observe(
verdict=VERDICT_GLOBAL, sender_id="Ann", sender_pubkey="ab", channel="#gen")
body = monitor.bot.command_manager.send_dm.call_args[0][1]
assert len(body.encode("utf-8")) == region_warning.DM_BODY_LIMIT
class TestReadHelpers:
def _seed(self, db, rows):
with db.connection() as conn:
for date, channel, scoped, globally, unknown in rows:
conn.execute(
"INSERT INTO region_scope_daily "
"(date, channel, scoped_count, global_count, unknown_count) "
"VALUES (?, ?, ?, ?, ?)",
(date, channel, scoped, globally, unknown),
)
conn.commit()
def test_traffic_summary_excludes_unknown_from_the_share(self):
"""A mesh the bot can't classify must read as no data, not as clean."""
db = _FakeDBManager()
today = datetime.now().date().isoformat()
self._seed(db, [(today, "#gen", 30, 10, 500)])
summary = region_warning.traffic_summary(db, _config(), days=7)
assert summary["channels"][0]["unscoped_pct"] == 25.0
assert summary["totals"]["unknown"] == 500
def test_traffic_summary_share_is_none_without_classified_traffic(self):
db = _FakeDBManager()
today = datetime.now().date().isoformat()
self._seed(db, [(today, "#gen", 0, 0, 12)])
summary = region_warning.traffic_summary(db, _config(), days=7)
assert summary["channels"][0]["unscoped_pct"] is None
assert summary["totals"]["unscoped_pct"] is None
def test_traffic_summary_ignores_rows_outside_the_window(self):
db = _FakeDBManager()
old = (datetime.now().date() - timedelta(days=30)).isoformat()
self._seed(db, [(old, "#gen", 5, 5, 0)])
assert region_warning.traffic_summary(db, _config(), days=7)["channels"] == []
def test_daily_series_zero_fills_quiet_days(self):
db = _FakeDBManager()
today = datetime.now().date().isoformat()
self._seed(db, [(today, "#gen", 1, 2, 3)])
series = region_warning.daily_series(db, _config(), days=5)
assert len(series) == 5
assert series[-1] == {"date": today, "scoped": 1, "global": 2, "unknown": 3}
assert series[0]["global"] == 0
def test_budget_counts_only_todays_decisions(self):
db = _FakeDBManager()
old = (datetime.now() - timedelta(days=2)).isoformat(sep=" ", timespec="seconds")
now = datetime.now().isoformat(sep=" ", timespec="seconds")
with db.connection() as conn:
for created_at in (old, now, now):
conn.execute(
"INSERT INTO region_warning_events "
"(created_at, sender_id, channel, delivery, action) "
"VALUES (?, 'Ann', '#gen', 'dm', 'sent')",
(created_at,),
)
conn.commit()
budget = region_warning.warning_budget(
db, RegionWarningSettings(max_warnings_per_day=6), _config())
assert budget["used_today"] == 2
assert budget["remaining"] == 4
def test_budget_reports_unlimited_when_cap_is_zero(self):
budget = region_warning.warning_budget(
_FakeDBManager(), RegionWarningSettings(max_warnings_per_day=0), _config())
assert budget["unlimited"] is True
assert budget["remaining"] is None
def test_read_helpers_tolerate_a_broken_database(self):
broken = Mock()
broken.execute_query.side_effect = sqlite3.OperationalError("no such table")
assert region_warning.recent_events(broken) == []
assert region_warning.traffic_summary(broken, _config())["channels"] == []
assert region_warning.warning_budget(
broken, RegionWarningSettings(), _config())["used_today"] == 0