Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions commands/adm.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
("command channel remove <cmd> <ch>", "stop a command responding on a channel"),
("radio pathhash [1|2|3]", "show/set this radio's outgoing path-hash width"),
("advert <flood|zero>", "send a flood or zero-hop advertisement"),
("advert interval <hours>", "send a flood advert every N hours (0 disables)"),
("status", "bot health and runtime stats"),
("reload", "rescan commands/ and reload plugins"),
("restart", "full teardown + reinit (re-reads config)"),
Expand Down Expand Up @@ -724,10 +725,30 @@ async def _radio_pathhash(ctx, args):
async def _cmd_advert(ctx, rest):
parts = rest.split()
if not parts:
return "Usage: !adm advert <flood|zero>"
return "Usage: !adm advert <flood|zero|interval <hours>>"
mode = parts[0].lower()
if mode == "interval":
if len(parts) < 2:
cur = ctx.bot.advert_interval_hours
return (
f"Flood advert interval: {cur}h"
f"{' (disabled)' if cur == 0 else ''}. "
"Set with: !adm advert interval <hours> (0 disables)"
)
try:
hours = int(parts[1])
except ValueError:
return f"Invalid interval: {parts[1]!r}. Use a whole number of hours."
try:
res = await ctx.bot.mgmt.radio_set_advert_interval(hours, **_actor(ctx))
except MgmtError as e:
return e.message
n = res["interval_hours"]
if n == 0:
return "Periodic flood advert disabled"
return f"Flood advert every {n}h"
if mode not in ("flood", "zero"):
return f"Unknown advert mode: {mode!r}. Use 'flood' or 'zero'."
return f"Unknown advert mode: {mode!r}. Use 'flood', 'zero', or 'interval'."
flood = mode == "flood"
try:
await ctx.bot.mgmt.send_advert(flood, **_actor(ctx))
Expand Down
26 changes: 26 additions & 0 deletions management.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,32 @@ async def send_advert(
)
return {"flood": flood, "ok": ok}

async def radio_advert_interval(
self, *, actor_pubkey=None, actor_name=None,
) -> dict:
"""Current periodic flood-advert interval (hours; 0 = disabled).
Read-only, not audited."""
return {"interval_hours": self.bot.advert_interval_hours}

async def radio_set_advert_interval(
self, interval_hours,
*, actor_pubkey=None, actor_name=None,
) -> dict:
"""Set the periodic flood-advert interval in hours (0 = disabled).
Persists to the database. Audited as 'radio.advert_interval'."""
try:
interval_hours = int(interval_hours)
except (TypeError, ValueError):
raise MgmtError("interval must be a whole number of hours", "invalid")
if not (0 <= interval_hours <= 168):
raise MgmtError("interval must be between 0 and 168 hours", "invalid")
await self.bot.set_advert_interval(interval_hours)
await self._audit(
actor_pubkey, actor_name, "radio.advert_interval", None,
f"interval={interval_hours}h",
)
return {"interval_hours": self.bot.advert_interval_hours}

# ==================================================================
# Radio contact-table rollover
# ==================================================================
Expand Down
7 changes: 7 additions & 0 deletions mcbot.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ owner_pubkeys = 0000000000000000000000000000000000000000000000000000000000000000
;radio_evict_max_per_run = 50
;radio_evict_min_interval = 120

; Periodic flood advert: send a flood advertisement every N hours (0 =
; disabled). This is only the FIRST-RUN SEED — on first start it is copied
; into the database, after which the DB value is authoritative and managed at
; runtime via '!adm advert interval N' or the web Manage->Radio page (changes
; persist across restarts). Edit this only to change the initial default.
;advert_interval_hours = 0

; Environment variables for the bot and command scripts.
; Each key here is exported into the process environment at startup, so
; command scripts can read it via os.environ (e.g. commands/pws.py reads
Expand Down
80 changes: 79 additions & 1 deletion mcbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,11 @@ class Config:
radio_evict_protect_types: set = field(default_factory=set)
radio_evict_max_per_run: int = 50 # safety cap on removals per run
radio_evict_min_interval: float = 120 # seconds debounce for auto runs
# Periodic flood-advert interval in hours (0 = disabled). This is only the
# SEED: on first run it is written to bot_meta, after which the DB value is
# authoritative and runtime-managed (via '!adm advert interval N' and the
# web Manage->Radio page). Edit this only to change the first-run default.
advert_interval_hours: int = 0
# --- web admin UI / API ([web] section) ---
web_enabled: bool = False
web_host: str = "127.0.0.1" # 0.0.0.0 to expose on all interfaces
Expand Down Expand Up @@ -538,6 +543,9 @@ def load_config(args) -> Config:
cfg.radio_evict_min_interval = parser["bot"].getfloat(
"radio_evict_min_interval", cfg.radio_evict_min_interval
)
cfg.advert_interval_hours = parser["bot"].getint(
"advert_interval_hours", cfg.advert_interval_hours
)
protect_raw = parser["bot"].get("radio_evict_protect_types", "")
if protect_raw.strip():
cfg.radio_evict_protect_types = parse_contact_types(
Expand Down Expand Up @@ -1243,6 +1251,13 @@ def __init__(self, cfg: Config, log: logging.Logger):
self.evict_headroom: int = cfg.radio_evict_headroom
self._evict_lock = asyncio.Lock()
self._last_auto_evict: float = 0.0 # monotonic
# periodic flood advert. seeded from cfg, then DB-authoritative (loaded
# via _load_advert_interval at startup, persisted by set_advert_interval).
# _last_flood_advert (monotonic) anchors the schedule; the periodic task
# measures the interval from it, and any flood advert (manual or
# periodic) refreshes it.
self.advert_interval_hours: int = cfg.advert_interval_hours
self._last_flood_advert: float = 0.0

# channel logging filter
def _parse_log_channels(self) -> None:
Expand Down Expand Up @@ -3477,8 +3492,46 @@ async def send_advert(self, flood: bool):
p.get("error_code") or p.get("code_string"),
p.get("reason"),
)
if flood:
# anchor the periodic-advert schedule to the most recent flood
# advert, whatever its source, so the interval is "at least N hours
# between flood adverts".
self._last_flood_advert = time.monotonic()
return ev

# --- periodic flood advert (interval seeded from config, then DB-managed) ---
async def _load_advert_interval(self) -> None:
# DB is authoritative; seed it from config on first run (key absent).
row = await self.db.fetchone(
"SELECT value FROM bot_meta WHERE key='advert_interval_hours'"
)
val = row["value"] if row else None
if val is not None and str(val).lstrip("-").isdigit():
self.advert_interval_hours = max(0, int(val))
else:
self.advert_interval_hours = self.cfg.advert_interval_hours
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('advert_interval_hours',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.advert_interval_hours),),
)

async def set_advert_interval(self, hours: int) -> None:
# update the runtime value, persist to the DB, and restart the schedule
# from now (so a change doesn't trigger an immediate overdue advert).
self.advert_interval_hours = max(0, int(hours))
self._last_flood_advert = time.monotonic()
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('advert_interval_hours',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.advert_interval_hours),),
)
self.logger.info(
"flood advert interval set to %dh%s",
self.advert_interval_hours,
" (disabled)" if self.advert_interval_hours == 0 else "",
)

async def send_channel_text(self, channel_idx: int, text: str):
# single-shot channel send (channel messages have no ACK). returns
# the radio Event (or None); logs a rejection. no exception on error.
Expand Down Expand Up @@ -3645,6 +3698,7 @@ async def run(self) -> int:
await self._program_channels_on_radio()
await self._bootstrap_admin_state()
await self.seed_command_configs()
await self._load_advert_interval()

# ensure radio contact-table headroom on startup (device_info +
# contacts have been synced above; owners are bootstrapped into
Expand Down Expand Up @@ -3750,15 +3804,39 @@ async def periodic_contacts():
except Exception:
self.logger.exception("periodic contacts sync failed")

async def periodic_advert():
# anchor the schedule to startup so the first periodic advert is a
# full interval away (no immediate flood on boot). Reads the
# runtime-mutable interval each tick, so changes take effect within
# the poll period without a restart.
self._last_flood_advert = time.monotonic()
while not self.stop_event.is_set():
try:
await asyncio.wait_for(self.stop_event.wait(), timeout=60.0)
break
except asyncio.TimeoutError:
pass
hrs = self.advert_interval_hours
if hrs and hrs > 0:
if time.monotonic() >= self._last_flood_advert + hrs * 3600:
try:
self.logger.info(
"periodic flood advert (every %dh)", hrs
)
await self.send_advert(flood=True)
except Exception:
self.logger.exception("periodic flood advert failed")

periodic_task = asyncio.create_task(periodic_contacts())
advert_task = asyncio.create_task(periodic_advert())

self._start_web()

self.logger.info("bot running; press Ctrl-C to stop")
try:
await self.stop_event.wait()
finally:
await self.shutdown([periodic_task])
await self.shutdown([periodic_task, advert_task])
return 0

def _start_web(self) -> None:
Expand Down
135 changes: 135 additions & 0 deletions tests/test_advert_interval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Periodic flood-advert interval: config seeds the DB on first run, then the
DB value is authoritative and runtime-managed (persisted, validated, audited).

Run: /home/steve/dev/meshcore/meshcore-bot/venv/bin/python tests/test_advert_interval.py
"""

import asyncio
import sys
import time
from pathlib import Path
from types import SimpleNamespace

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import mcbot # noqa: E402
from management import MgmtError # noqa: E402

_failures = 0


def check(cond, msg):
global _failures
print(f" {'ok' if cond else 'FAIL'}: {msg}")
if not cond:
_failures += 1


def make_bot(*, advert_interval_hours=0):
cfg = mcbot.Config()
cfg.db_path = Path(":memory:")
cfg.advert_interval_hours = advert_interval_hours
log = mcbot.logging.getLogger("test-advert")
log.addHandler(mcbot.logging.NullHandler())
log.propagate = False
return mcbot.MCBot(cfg, log)


async def meta(bot):
row = await bot.db.fetchone(
"SELECT value FROM bot_meta WHERE key='advert_interval_hours'"
)
return row["value"] if row else None


async def test_seed_from_config_when_db_empty():
print("test_seed_from_config_when_db_empty")
bot = make_bot(advert_interval_hours=3)
await bot._load_advert_interval()
check(bot.advert_interval_hours == 3, "seeded interval from config")
check(await meta(bot) == "3", "config value written to bot_meta")
bot.db.close()


async def test_db_is_authoritative_over_config():
print("test_db_is_authoritative_over_config")
bot = make_bot(advert_interval_hours=3)
await bot.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('advert_interval_hours','5')"
)
await bot._load_advert_interval()
check(bot.advert_interval_hours == 5, "DB value wins over config")
bot.db.close()


async def test_set_persists_and_resets_schedule():
print("test_set_persists_and_resets_schedule")
bot = make_bot()
bot._last_flood_advert = 0.0
await bot.set_advert_interval(7)
check(bot.advert_interval_hours == 7, "in-memory value updated")
check(await meta(bot) == "7", "value persisted to bot_meta")
check(bot._last_flood_advert > 0, "schedule baseline reset to now")
await bot.set_advert_interval(0)
check(bot.advert_interval_hours == 0 and await meta(bot) == "0", "disable persists")
bot.db.close()


async def test_mgmt_validation_and_audit():
print("test_mgmt_validation_and_audit")
bot = make_bot()
r = await bot.mgmt.radio_set_advert_interval(4)
check(r == {"interval_hours": 4}, "valid set returns interval")
check(await meta(bot) == "4", "mgmt set persisted")
g = await bot.mgmt.radio_advert_interval()
check(g == {"interval_hours": 4}, "get returns current interval")
for bad in (-1, 169, "abc"):
try:
await bot.mgmt.radio_set_advert_interval(bad)
check(False, f"invalid interval {bad!r} should raise")
except MgmtError:
check(True, f"invalid interval {bad!r} rejected")
# audit row written for the successful set
row = await bot.db.fetchone(
"SELECT detail FROM bot_audit_log WHERE action='radio.advert_interval' "
"ORDER BY id DESC LIMIT 1"
)
check(row is not None and "interval=4h" in row["detail"], "set is audited")
bot.db.close()


async def test_send_advert_anchors_schedule():
print("test_send_advert_anchors_schedule")
bot = make_bot()

async def fake_send_advert(flood=False):
return SimpleNamespace(type=SimpleNamespace(name="OK"), payload={})

bot.mc = SimpleNamespace(commands=SimpleNamespace(send_advert=fake_send_advert))
bot._last_flood_advert = 0.0
await bot.send_advert(flood=False)
check(bot._last_flood_advert == 0.0, "zero-hop advert does not anchor schedule")
await bot.send_advert(flood=True)
check(bot._last_flood_advert > 0, "flood advert anchors schedule")
bot.db.close()


async def main():
for t in (
test_seed_from_config_when_db_empty,
test_db_is_authoritative_over_config,
test_set_persists_and_resets_schedule,
test_mgmt_validation_and_audit,
test_send_advert_anchors_schedule,
):
await t()
print()
if _failures:
print(f"FAILED: {_failures} check(s)")
sys.exit(1)
print("ALL TESTS PASSED")


if __name__ == "__main__":
asyncio.run(main())
Loading
Loading