mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 06:12:19 +00:00
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:
commit
c573755324
29 files changed
+5152
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 — 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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 · ' + c.scoped + ' scoped'
|
||||
+ (c.unknown
|
||||
? ' · <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
|
||||
? ' · ' + 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 — '
|
||||
+ '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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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"')
|
||||
@@ -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
|
||||
@@ -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("*") == "*"
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user