Commit Graph
126 Commits
Author SHA1 Message Date
agessaman eed455ce85 refactor(command_manager): send_channel_message and its budget predictor share one scope resolution
S3, scoped to what was still duplicated. effective_channel_send_scope exists to predict the scope send_channel_message will apply, and the two carried the same resolve → outgoing override → global-marker test → normalize steps. Both now call _send_scope(scope, channel), which returns (resolved, scope_to_use, is_global); send_channel_message still uses resolved for its scope-source log line, and the predictor still maps a global scope to None and any exception to "#unknown". The plan's ScopeResolution record is not needed: flood_scope.py already holds the shared lookups (channel_scope_entry, outgoing_override, section_flood_scope) and the three public contracts call them. The scope matrix golden passes unchanged.
2026-10-03 22:48:59 -07:00
agessaman 495df80e2f refactor(commands): choose the help call by signature instead of retrying on TypeError
Codex found that after 7aba432b a TypeError raised inside one of the 40 newly widened help bodies reached the retry in help and command_manager, so the body ran a second time (and could then succeed); before, the no-message call ran it once and the error propagated. Both call sites now go through base_command.help_text_for(command, message), which binds the message against get_help_text's signature first: commands that accept it get get_help_text(message), local plugins written as get_help_text(self) get get_help_text(), and an error from the body propagates after one call. A callable with no introspectable signature keeps the old try-and-retry.

Accepted residual difference: joke and rain already took the message, and a TypeError raised inside their help bodies used to be retried without it; it now propagates. help's no-message test now uses a real get_help_text(self) instead of a Mock that raised TypeError, and a new test pins the single call.
2026-10-03 22:43:24 -07:00
agessaman e359d8d01f Merge origin/dev (#335, #336, #338, #341) into refactor/maintainability
command_manager: _record_transmission passes extra keyword arguments through, so #336's recipient_key (DMs) and channel_idx (channels) reach record_transmission as on dev.

graph_trace_helper: dev's rewrite from #336, with its two repeater-key lookups (immediate neighbor and _unique_repeater_key) going through contacts_repo.unique_recent_repeater_key, whose queries are identical.
2026-10-03 09:41:56 -07:00
agessaman 323ebedeae fix(transmission_tracker): match a sent message only to its own packet
match_packet_hash gave a pending transmission the hash of the first packet heard within its 30-second window, whatever the packet was. On a busy mesh that was usually someone else's message or advert, so its repeats were counted as ours (the web viewer's repeat counts, DARC MoWaS's ACK check, graph_trace_helper's "our trace" test) and our real repeats were not.

A transmission now records what identifies its packet (the channel index for a channel message, the recipient's key for a DM, the tag for a trace) and match_packet_hash claims it only for a packet carrying the same: a GRP_TXT that decrypts on that channel to the same text, a TXT_MSG addressed to the recipient's hash and, when the radio's key is known, from ours, or a TRACE with the same tag. The RX handler passes that evidence through _transmission_evidence, reusing the authenticated channel decode. Without evidence, match_packet_hash only looks up hashes already matched, so record_repeat and graph_trace_helper no longer claim pending transmissions.

Repeat-count writes go to a single worker thread instead of running sqlite on the event loop, and _update_command_in_database no longer inserts into sys.path on every repeat.

bot_prefix still reads meshcore.device and stays None; fixing it turns on graph_trace_helper's trace updates, which have never run, so it is a separate change.
2026-10-02 16:51:15 -07:00
agessaman 5735483f20 Merge dev through #320 into refactor/maintainability
Brings in #309, #311, #314, #317, #319 and #320. Conflicts: command_manager's imports (kept both `inspect` and `math`) and wx's alert send, where I kept the S1 removal of the dead `weather_data[3]` statement and took #319's `skip_user_rate_limit=True`. The NOAA wx goldens are regenerated because #320 changes the wind text (`💨N0` → `⬆️N0`); nothing else in them moved.
2026-10-01 18:45:39 -07:00
agessaman 4d16764f1e Merge remote-tracking branch 'origin/dev' into fix/dm-ack-timeout-nan
# Conflicts:
#	CHANGELOG.md
2026-10-01 15:46:26 -07:00
agessaman b750ad43f0 Merge origin/dev (#313) into refactor/maintainability
core.py conflicts were S3's simplifications of is_radio_offline, _record_send_failure and _record_send_success colliding with #313's rewrite of the same methods; dev's versions are kept.
2026-10-01 11:53:01 -07:00
agessaman 8631d2f8bb fix(dm): treat a NaN dm_min_ack_timeout as unset
configparser's getfloat accepts 'nan', and NaN fails every comparison, so it passed both the negative-value check and the min() cap and reached send_msg_with_retry as the ACK wait. It now falls back to the default like a negative value. 'inf' was already capped at 60 s; both cases are in the parametrized test, and the nan case fails on dev.
2026-10-01 11:39:32 -07:00
agessaman 49960e50bf fix(core): let a reply take the offline trial, so bots without schedules recover too
Only scheduled messages and interval adverts could claim the trial armed by an answered health probe, so a bot with neither stayed offline however healthy the radio became. send_dm and send_channel_message now claim an armed trial when offline and run the send through _send_as_offline_trial, under the trial's identity; its result settles the trial (True clears the outage, False or an exception fails it until the next answered probe), so a dead radio still gets at most one attempt per probe. Settlement runs in a worker thread because it can write bot_metadata. Tests drive the real CommandManager guard, and the alert-thread counting in the recovery tests no longer stops asyncio.to_thread's workers.
2026-10-01 11:18:21 -07:00
agessaman 0332499f65 Merge origin/dev (#315) into refactor/maintainability; restore deleted public methods
#315 changed populate_missing_geolocation_data, which S1 had deleted as dead. It is public, and local plugins may call public manager and command methods, so it comes back in dev's form, along with 11 other public methods S1 removed: CommandManager.get_available_commands_list, GreeterCommand.get_recent_greeted_users, WxCommand.abbreviate_alert_title and get_current_conditions, MessageHandler.discover_message_path, and RepeaterManager.add_discovered_contact, get_daily_advertisement_stats, get_nodes_per_day_stats, get_purging_stats, restore_repeater, test_meshcore_cli_commands and toggle_auto_add. All are verbatim from dev; every self. attribute they use still exists. A scan of every public name in modules/ on dev against this branch now finds nothing missing.
2026-10-01 09:26:01 -07:00
agessaman 411cf796f0 refactor(command_manager): drop branches for meshcore APIs every supported version has
meshcore>=2.3.14 always provides commands.send_msg_with_retry and commands.set_flood_scope, so _send_dm_payload's else-branch to send_msg and the set_flood_scope hasattr check (with its 'unavailable' warning) were unreachable. The except AttributeError around the retry call stays: it also catches attribute errors raised inside the retry and re-sends with send_msg.
2026-10-01 09:21:52 -07:00
agessaman 7d61765427 Merge remote-tracking branch 'origin/dev' into refactor/maintainability
# Conflicts:
#	modules/core.py
2026-10-01 09:18:31 -07:00
agessaman eb1f14479e Merge remote-tracking branch 'origin/dev' into fix/dm-ack-timeout-floor
# Conflicts:
#	CHANGELOG.md
2026-10-01 08:15:55 -07:00
agessaman efe074b151 refactor: restore behavior the branch had changed (codex review)
A codex review of the branch found seven places where earlier refactor commits changed behavior. Each is restored here with a test:

- base_service imported flood_scope relatively; the local-service loader also runs that file as local_services.base_service, where the import cannot resolve. Now absolute.
- Radio-health fields were initialized after commands and services were built, so a plugin constructor reading is_radio_offline raised. They are now set before any plugin.
- Removing the per-command can_execute overrides made 31 commands honor skip_channel_check, which their overrides had dropped. BaseCommand.honors_skip_channel_check (False on exactly those commands) keeps them enforcing their channel list.
- CommandManager.get_plugin_metadata, WxCommand.get_weather_for_zipcode and PluginLoader.validate_plugin were public compatibility entry points; they are back.
- feed_manager stopped exporting DEFAULT_MAX_FEED_RESPONSE_BYTES and DEFAULT_MAX_PARSED_FEED_ITEMS despite the commit saying it re-exported them; it does now.
- wx and gwx logged companion-location lookup failures at WARNING; the shared helper logged at debug. It now takes the level.
- The INFO 'MESSAGE ROUTING' / 'DIRECT MESSAGE' lines for pubkey-correlated DMs were removed with the dead path_info computation; they are logged again by a log-only helper.
2026-10-01 06:53:14 -07:00
agessaman 2066f78c42 refactor: read the radio's own name through models.self_info_name
Five copies read meshcore.self_info as a dict or an object with slightly different rules. self_info_name(self_info, fields, by_presence=False) returns the first truthy field, or with by_presence the first field an object has at all, which is how BaseCommand has always read objects (so name='' on an object still falls back to config there, but not in CommandManager). Each caller passes its own field order and keeps its own fallback: budgets read name/user_name, mention matching reads name/adv_name. The device-name sync in core is left alone, since it logs the raw value. The identity golden file is unchanged.
2026-10-01 02:02:34 -07:00
agessaman 9b29ba4261 refactor(flood_scope): share the config lookups behind every scope resolver
MeshMessage.effective_outgoing_flood_scope, CommandManager's per-channel and override readers and its config_section branch, and BaseServicePlugin.get_mesh_flood_scope each scanned config.ini themselves. modules/flood_scope.py now owns those reads: channel_scope_entry (stripped flood_scope.<channel>, None with no entry), outgoing_override, and section_flood_scope (normalized, None when empty). Callers keep their own contracts on top: MeshMessage still reports raw values, CommandManager still normalizes, and None still means 'apply the override later' in resolve_channel_send_scope. MeshMessage.is_global_flood_scope delegates to is_global_marker, the identical predicate. The scope matrix golden file is unchanged.
2026-10-01 01:59:03 -07:00
agessaman 2fbe0aff01 refactor: one accessor for the web viewer bridge
Six places in message_handler and command_manager spelled out hasattr(bot, 'web_viewer_integration') and ... and ....bot_integration, then reached through the same chain again to call it. A _viewer_bridge() method on each class returns the bridge or None, and the call sites bind it once with :=.
2026-10-01 01:45:54 -07:00
agessaman 42c87ca6b8 refactor(command_manager): extract the send bookkeeping repeated across send paths
Four helpers replace copy-pasted blocks, each keeping the original order of side effects:

- _record_successful_send(rate_limit_key): the global/TX/per-user limiter charges, pasted three times in _handle_send_result
- _record_transmission(content, target, type, command_id): repeat-tracking registration, identical in send_dm and send_channel_message apart from target and type, returning the (possibly generated) command id
- record_command_stat(message, name, response_sent): the guarded stats write, seven copies in this module
- _chunk_spacing_seconds(): the pause between parts of a multi-part send, three copies

message_handler's stats writes go through bot.command_manager.commands directly and tests assert on that, so they are left as they are.
2026-10-01 01:28:01 -07:00
agessaman 06a4db114c refactor: remove branches for APIs that always exist
meshcore >= 2.3.14 is required and installed, and these guards protected against its absence or against first-party modules failing to import:

- channel_manager: the meshcore_cli get_channel fallback beside commands.get_channel
- core: the set_name hasattr check (an absent method still lands in the existing AttributeError handler and returns False), the wait_for_contacts polling loop after an always-true hasattr(meshcore, 'contacts'), and the hasattr check before _start_queue_processor
- wx and gwx: try/except ImportError around wx_international and wxsim_parser, which import only required dependencies, and the 'parser not available' branches that could never run

Branches that also catch runtime errors raised inside an existing API stay, as do the DM retry and set_flood_scope paths, which open PRs are changing. The inspect.signature checks in execute_commands become one _call_with_optional_user_id helper, keeping zero-argument cooldown hooks working for local plugin commands.
2026-10-01 01:15:19 -07:00
agessaman d3a7bf95a4 refactor: delete functions and attributes nothing calls
Every name removed here has zero references across modules, tests, docs, local plugins, scripts and generate_website.py, and none is part of the documented plugin surface (db_manager, command_manager send methods, channel_manager stay untouched). About 1,160 lines:

- multitest: four formatter helpers that only referenced each other (the flat and nested variants are the ones in use)
- path: _get_api_cache_data, _filter_recent_repeaters
- repeater command: _handle_contact_stats, _get_geocoding_status
- airplanes: _send_split_response; greeter: get_recent_greeted_users; plugin_loader: validate_plugin
- wx: get_weather_for_zipcode, abbreviate_alert_title, get_current_conditions
- message_handler: discover_message_path, and an ADVERT debug line behind a self.debug attribute that is never set
- utils: _get_node_location_and_key_from_db
- repeater_manager: test_meshcore_cli_commands, toggle_auto_add, restore_repeater, get_purging_stats, populate_missing_geolocation_data, get_daily_advertisement_stats, get_nodes_per_day_stats
- command_manager: get_available_commands_list and get_plugin_metadata (HelpCommand and PluginLoader have the live versions)
- base_command: _load_command_prefix, and a no-op .replace('_', '_')
- scheduler: the first of two _run_data_retention definitions, shadowed by the second since it was added
- core: the write-only last_clock_sync_time, and _setup_routing_capture, which only logged a line

Full suite, ruff and mypy pass, and the characterization golden files are unchanged.
2026-10-01 01:07:29 -07:00
agessaman b7e1b5b3da fix(send): detect a radio rejecting set_flood_scope
send_channel_message compared the set_flood_scope reply's type to the string "ERROR", but it is an EventType whose value is "command_error", so the comparison never matched. A rejected regional scope sent the message at whatever scope the radio held, and a rejected restore to global flood left the radio pinned to the region for every later send, both without a log line. Compare against EventType.ERROR.

The existing tests for this path built their error reply with the same "ERROR" string, so they passed against the broken check; they now use EventType.ERROR and fail without the fix.
2026-10-01 00:39:55 -07:00
agessaman 2cc50c5878 fix(dm): cap dm_min_ack_timeout and correct how the firmware estimate is described
Review follow-up. The firmware's direct-path estimate includes hop count and about 750 ms of fixed allowance, not airtime alone, so the comments and changelog now say that. Values above 60 seconds are capped, since a value meant as milliseconds would otherwise hold one DM for hours, and the changelog notes that an unacknowledged DM now takes at least 24 seconds to report failure. The late-ACK test has a wider margin so it cannot flake on a loaded machine.
2026-10-01 00:27:21 -07:00
agessaman 5452fd58ee fix(dm): wait at least dm_min_ack_timeout for each DM attempt's ACK
send_msg_with_retry was called with timeout=0, so each attempt waited 1.2 times the radio's suggested_timeout. The companion firmware derives that from airtime alone (500 + (6 x airtime + 250) x (hops + 1) ms direct, 500 + 16 x airtime flood), but the round trip also carries fixed costs that do not shrink with airtime: the recipient's ACK delay, repeater retransmit and rx delays, the radio's TX queue and the host link. On a fast preset a zero-hop attempt gave up after about 1.3 seconds, the ACK of a delivered message arrived after the last attempt, and the send reported failure. That is the residual #290 symptom: webhook DMs erroring within a couple of seconds for messages the recipient had.

New [Bot] dm_min_ack_timeout (default 8 seconds) is passed as min_timeout. An ACK still ends the wait at once, so successful sends are no slower, and since the radio lock is per-frame the longer wait on a failing DM does not hold up other sends. 0 restores the old behavior. The new test drives the real meshcore retry loop with an ACK slower than the radio's estimate and fails without the floor.
2026-09-30 22:20:10 -07:00
CoderNemesis 5175558dd7 fix(send): split oversized channel text before it stalls the radio (#299)
An oversized webhook or channel body was handed to the firmware whole. The device dropped it without an ack, retries stalled the transport, and the radio looked dead. Split to the frame budget, keep links intact, and tag multi-part sends so they can be reassembled.

Reserve the wx location prefix from that same budget, and let NOAA alert polling be turned off without disabling the rest of the weather service.
2026-09-22 16:21:54 -07:00
agessaman b5e61306b2 Merge remote-tracking branch 'origin/dev' into feat/region-code-warnings
# Conflicts:
#	CHANGELOG.md
2026-09-20 10:43:24 -07:00
Adam Gessaman 38b259156e chore(deps): require meshcore 2.3.14 for the send_msg_with_retry ACK fix (#294)
The ACK fix I wrote for the radio-lock work is now upstream and released
(meshcore_py#108, v2.3.13). Before it, send_msg_with_retry reported ACKed DMs as
failures: the ACK subscription was registered only after send_msg returned, so
an ACK queued right behind MSG_SENT was dispatched with no listener, and each
attempt accepted only its own ACK code, so a late ACK answering an earlier
attempt was ignored. The bot logged "no ACK received after retries" and skipped
the delivery bookkeeping for messages the recipient had in fact received. Pin
2.3.14, which also fixes send_cmd's destination-type handling.

Two fixes from re-reading the merged radio-lock change:

A region-scoped channel message now restores global flood even when
set_flood_scope raises. The restore only ran in the finally around the send, so
a set that raised left the device pinned to that region and every later send
went out under it.

Drop the __setattr__ left behind when _SerializedCommands became
_serialize_command_frames; it sat after the function's return, unreachable, and
still referenced the proxy's _commands slot.
2026-09-19 17:28:14 -07:00
Adam Gessaman 9939e22575 fix(core): release the radio lock while waiting for ACKs (#291)
Radio commands were serialized per method call, so a composite command held the lock for everything it awaited. A DM's send_msg_with_retry kept every other command waiting through all of its ACK timeouts (up to ~36 s with three attempts), stalling channel replies, other DMs, scheduled sends, and automatic message fetching. req_regions_sync did the same for each neighbor scope reply.

Serialize at the frame instead: wrap CommandHandler.send() on the handler instance, which is where every meshcore command writes its frame and waits for the radio's immediate reply. There is still at most one in-flight companion frame, paced as before, and library methods that call self.send are covered without a proxy. Waits for ACKs and remote responses now run outside the lock.

Add MeshCoreBot.radio_session() for short sequences that must not interleave, and use it for region-scoped channel sends so set scope, send, and restore stay together. The per-call lock never guaranteed that: a DM retry loop queued between set_flood_scope and send_chan_msg sent its flood attempts under the channel's scope.
2026-09-19 09:12:07 -07:00
agessaman 16cce92c46 feat(region): set region scopes from the Radio page (#283)
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.

Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command 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. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.

Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing 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, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.

A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.

The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.

Also from building it:

- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
  still honours it when [Channels] is empty, so a page that ignored it would
  show "replies to every scope" while the bot enforced an allowlist — and
  saving "off" hands the allowlist straight back to [Bot], which the banner now
  says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is 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".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
  process, can validate a typed name without importing the bot's command
  machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
  light #dee2e6 onto a dark card. All four directional utilities now match.
2026-09-16 07:59:10 -07:00
agessaman 0c1aa4fd15 fix(region): never warn the synthetic "Channel User" sender
A channel message with no "Name: " prefix falls back to a stand-in name.
That is not a node: every such message shares the identity, so they would
accumulate a run together and then get a DM addressed to a contact that
does not exist. The hook now passes no sender for those, so they are still
counted but can never earn a warning. The literal is a named constant and a
test pins the hook's guard to it.

Also folds the channel body-budget formula into models.channel_body_limit
rather than letting the web viewer keep a fourth copy of
"max(130, 160 - len(name) - 2)"; BaseCommand and CommandManager now call it
too, and 158 becomes models.DM_BODY_LIMIT.
2026-09-15 22:39:34 -07:00
agessaman d3a435d9ed fix(help): preserve multiword aliases in fallback 2026-09-15 21:01:39 -07:00
agessaman a861e848af fix(commands): handle subcommand help and test escapes (#285, #277) 2026-09-15 20:57:49 -07:00
agessaman cf26da652d fix: log command failures with exception() instead of an inlined frame
Addresses the review on #243. The original change interpolated the last
traceback frame into an `err_desc` string used in two places, and the second
one was the problem: `errors.execution_error` is sent back over the mesh, so
an absolute install path went out over RF and spent airtime on the error
path, where retries are most likely.

logger.exception() puts the full traceback in the log instead of just one
frame, which is strictly more than the original wanted, and the RF reply goes
back to str(e). That also removes the `list(tb.stack)[-1]` IndexError on an
empty stack, since the frame handling is gone entirely.

Tests cover both halves: the failure is logged via exception(), and the reply
carries only the exception text with no filesystem path.

CHANGELOG entry moves under the existing `## [Unreleased]` / `### Fixed`.
2026-09-07 16:41:47 -07:00
Gerard Hickey cd54b36f7a fix: Add traceback info for failed commands
Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
2026-09-07 16:35:31 -07:00
agessaman 3b5e95fb93 fix(command_manager): prevent a disabled command from swallowing a keyword preventing an alias from firing
Removed redundant logging for command execution and added detailed debug logging for cases where a command cannot execute due to soft rejections. This allows for better tracking of command flow and ensures that keyword matching continues for subsequent commands. Updated the test_command to include config aliases in keyword matching, enhancing its flexibility.
2026-08-24 16:03:22 -07:00
agessaman fc63039a48 fix: address Codex review of tonight's work
Correctness:

- {path_distance} was always blank in production. The resolution code that
  builds repeater_info dropped latitude/longitude in every branch, so the
  calculator could never find a coordinate. My tests passed hand-built
  dicts straight to the calculator and never exercised the builder, which
  is why they stayed green. Coordinates are now carried through all four
  construction sites, and the new tests drive _lookup_repeater_names via
  its lookup_func hook so the real builder runs.

- The #80 route guard was defeated two ways in the channel handler. When
  the RF data was an uncorrelated fallback, control fell through to the
  raw-hex and routing_info fallbacks below, which took the route from the
  unrelated packet anyway; the guard had actually made that path
  reachable. message.routing_info was also assigned unconditionally, and
  the path command reads it. "Not attributable" is now a terminal branch
  and the routing_info hand-off checks provenance.

- Same fix was incomplete for DMs: routing_info was captured and turned
  into path_info before the provenance check ran, so the later check only
  declined to overwrite an already-wrong value. Guarded at the source.

- Rendering could transmit for real. Capture only intercepts
  send_response, but advert calls send_advert() directly and
  send_response_chunked never checked capture_sink. Chunked sends are now
  captured, and rendering is opt-in via BaseCommand.render_safe (default
  False) instead of a denylist that cannot be complete. This also closes
  the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
  longer broadcast configuration to a channel.

- Multi-part rendered output was rejoined into one oversized send.
  Scheduled messages are now split to the RF body budget and sent through
  send_channel_messages_chunked, on character boundaries so multi-byte
  text is not corrupted.

- _last_path_distance_km is instance state that was only set on success,
  so an invalid path request could show the previous request's distance.
  Reset at the start of every execute().

- Stale-contact retries were only counted on non-OK results, so timeouts
  and exceptions left a contact eligible forever and could recreate the
  storm. All failed attempts count now.

Web viewer:

- A failed config reload was reported as a successful save. The API and
  UI now distinguish "saved and active" from "saved, restart needed".

- Preview count was unbounded; clamped to 1-20.

- update_ini_values is a read-modify-replace with no locking, so two
  concurrent viewer saves could lose one. Serialised behind a lock.
2026-08-22 00:16:03 -07:00
agessaman a764186eb2 feat(scheduler): airtime guards for {cmd:...}, plus missed docs
Two guards on command placeholders in scheduled messages, neither
configurable, because both exist to protect a shared medium:

A 15-minute floor. A schedule containing {cmd:...} that fires more often
is rejected at startup with an error rather than quietly running slower.
The interval is sampled and measured by the tightest gap between
firings, so "0,1 * * * *" is correctly treated as every 60 seconds and
not as hourly. Schedules without a command placeholder are unaffected.

The command's own cooldown_seconds now applies to a render. Scheduling
is not a way around the rate a command was configured to run at. The
execution is recorded before the command runs, matching execute_commands,
so a slow or failing render cannot be retried straight past the cooldown.

Also fills documentation gaps from the preceding commits:

- --install-extras was only in the script's own --help; documented in
  service-installation.md (including alongside --update-venv) and
  upgrade.md.
- weather-service.md documented weather_alarm's once-a-day scheduling
  with no route to more than one forecast a day, which is exactly what
  people go there looking for. It now points at {cmd:wx ...} and notes
  that sunrise/sunset still belong to weather_alarm.
2026-08-21 21:33:54 -07:00
agessaman b0148506a6 feat(scheduler): broadcast any command's output with {cmd:...}
A scheduled message can now embed the reply of any bot command:

    [Scheduled_Messages]
    0 6,12,18 * * * = Public:{cmd:wx Seattle}

The schedule side was never the missing piece — [Scheduled_Messages]
keys have been 5-field cron with @presets for a while, so multiple
times, intervals and @hourly were all already expressible. What was
missing was any way to get a service's output into a scheduled message:
placeholders were limited to mesh info (contact counts). That gap is
why services were growing their own schedule parsers.

CommandManager.render_command_output runs a command for its text alone.
MeshMessage.capture_sink makes that safe: when set, send_response
collects the reply and transmits nothing, checked before the
_last_response bookkeeping so a background render cannot overwrite the
response captured for a real user's command.

Degrades quietly in every failure mode — unknown, disabled, admin-only,
non-renderable, timed out or silent commands expand to nothing, so raw
{cmd:...} text is never put on the air, and a message left empty is not
sent at all. Command output is not re-scanned, so a reply containing
{cmd:...} cannot recurse. Commands that transmit directly rather than
returning text (announcements) are refused outright, since rendering
them would broadcast for real.

Bounded by [Bot] scheduled_command_timeout_seconds (default 30).
2026-08-21 21:25:16 -07:00
agessaman ddf8884196 fix(command_manager): resolve DMs from pending_contacts
Newly advertised companions stay in meshcore pending_contacts until the
next contact snapshot, so keyword replies failed after a successful ACK.
2026-08-21 09:40:05 -07:00
agessaman 119fce2211 feat(command-manager): implement DM length guard and auto-splitting for oversized messages
- Added a mechanism to check the byte length of direct messages (DMs) before sending, ensuring they do not exceed the firmware's maximum limit.
- Implemented a method to split oversized messages into smaller chunks, preserving UTF-8 encoding and avoiding mid-codepoint splits.
- Enhanced logging to provide warnings when messages are auto-split, including details on the number of chunks created.
- Updated the DM sending logic to handle both single and split messages efficiently.
- Introduced a new static method for splitting text into UTF-8 chunks, improving message handling across the application.
2026-07-16 14:56:42 -07:00
agessaman 9ae9ebb327 Harden P0 security and release boundaries 2026-07-16 10:21:50 -07:00
agessaman 2e8ccf283a fix(settings-ui): make config reload honor deletions and cached command settings
- reload_config: clear all sections before re-reading so keys deleted from
  config.ini (e.g. rows removed in the settings UI) don't survive in memory
  (ConfigParser.read merges instead of replacing)
- reload_config: re-instantiate command plugins so settings cached in
  __init__ (including 'enabled') take effect on reload
- /api/plugins save: treat dynamic_sections / repeating_blocks absent from
  the payload as 'don't touch' instead of 'delete all managed keys'
- settings view: respect per-plugin settings_enabled_default so opt-in
  commands (Announcements, Greeter) display as off when unconfigured
- ruff: drop unused import, organize command_manager imports
- add tests for ini_writer, settings_schema, settings_store and the
  /api/plugins endpoints (38 cases) plus reload regression tests
2026-07-12 11:57:28 -07:00
agessaman 95b0601980 feat(command_prefix, config): enhance command prefix handling and documentation
- Updated `config.ini.example` to support single, multiple, and decorative command prefixes, improving flexibility in command invocation.
- Refactored `CommandManager` and `BaseCommand` to utilize new command prefix parsing logic, allowing for better handling of multiple prefixes and optional bare commands.
- Enhanced documentation to clarify command prefix configuration options and behavior, ensuring users understand how to set and use prefixes effectively.
- Added unit tests to verify the correct functionality of command prefix handling, including support for permissive and strict prefix modes.
2026-07-07 09:37:28 -07:00
agessaman b8decd8d66 feat(command_manager): enhance help command channel access handling
- Updated the `CommandManager` to respect the `channels` override for the help command, ensuring it checks channel permissions directly when processing help requests.
- Refactored the logic to fall back to global `monitor_channels` only when no specific help command is loaded, improving channel-specific access control.
- Added unit tests to verify the correct behavior of the help command in disallowed channels, ensuring it only responds in permitted contexts.
2026-06-28 09:52:37 -07:00
agessaman 0c37f48a45 feat(command_manager, help_command): enhance help command handling and testing
- Updated the `CommandManager` to respect the `help_enabled` flag for the help command, ensuring it only responds when the command is enabled.
- Refactored help request processing to streamline checks for help keywords and channel restrictions.
- Added unit tests to verify the behavior of the help command when enabled and disabled, ensuring proper suppression of responses when the command is not active.
2026-06-28 09:45:58 -07:00
agessaman 24fdfb2370 feat(command_manager, help_command): improve command prefix handling and help command visibility
- Enhanced `CommandManager` to persist normalized message content, preventing prefix stripping from interfering with command matching.
- Updated `BaseCommand` to skip prefix handling if already normalized, ensuring all commands can be matched correctly.
- Modified `HelpCommand` to ensure unused commands are listed even when command statistics are present, improving user accessibility to all commands.
- Added unit tests to verify the correct behavior of command matching and help command functionality under various scenarios.
2026-06-28 08:42:58 -07:00
agessaman 6584800cb7 feat(command_manager, models): enhance flood scope handling for channels
- Introduced per-channel flood scope configuration in `config.ini.example`, allowing for more granular control over message routing.
- Updated `CommandManager` to normalize channel names and resolve flood scopes based on channel-specific settings, improving message delivery accuracy.
- Enhanced `MeshMessage` to consider channel-specific flood scopes when determining the effective outgoing flood scope.
- Added unit tests to verify the new flood scope resolution logic and ensure correct behavior across various scenarios.
2026-06-27 17:09:28 -07:00
agessaman cb54ca6a45 fix(config): clarify flood scope configuration and logging behavior
- Updated `config.ini.example` and `configuration.md` to enhance clarity on the `outgoing_flood_scope_override` and `flood_scopes` settings, detailing their behavior and interactions.
- Improved logging in `CommandManager` to provide better insights into scope resolution and potential issues during message sending.
- Added new methods in `MessageHandler` for improved RF data correlation, ensuring eligibility checks for flood scope matching.
- Enhanced unit tests to cover new behaviors and ensure robust handling of flood scope configurations.
2026-05-17 14:38:47 -07:00
agessaman 61ee86168b chore(workflows): exclude dist directory from shellcheck
Updated the GitHub Actions workflow to exclude the dist directory from shellcheck checks, ensuring cleaner linting results.

fix(command_manager): reorder datetime import

Moved the datetime import to the correct position in command_manager.py for better code organization.

fix(reload_config): improve config value retrieval

Refactored the _read_config_value function in reload_config.sh to ensure default values are returned correctly when the key is not found in the config file.

fix(tests): adjust imports in test_scheduler_logic

Updated import statements in test_scheduler_logic.py for consistency and clarity.
2026-05-16 12:11:51 -07:00
agessaman 74b7de6f72 feat(flood_scope): enhance flood scope handling across services
- Introduced optional regional TC_FLOOD scope configuration for various services, allowing for more granular control over message routing.
- Updated the CommandManager to resolve and apply flood scopes from messages, configuration sections, and explicit parameters, ensuring correct message delivery.
- Enhanced service plugins to utilize the new flood scope functionality, including weather, earthquake, and webhook services.
- Added unit tests to verify the correct resolution and application of flood scopes in different scenarios, ensuring robust functionality.
2026-05-16 12:08:24 -07:00
agessaman d038402875 fix(CommandManager): use sender_pubkey for DM responses to prevent misrouting
Updated the CommandManager to prioritize sender_pubkey over sender_id when sending direct messages. This change ensures correct routing of messages, especially in cases where multiple nodes share similar display names. Added tests to verify the new behavior and ensure proper functionality.
2026-05-16 10:08:18 -07:00