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
17 changes: 11 additions & 6 deletions Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,12 +889,17 @@ in one reply. Arguments:
message → `direct (no path)`.
- Reliability note: path hashes can collide (several repeaters share the same
leading byte(s)), so results are most reliable on 2- and 3-byte paths. Hops
resolve only against repeater/room contacts. When a hash matches more than
one repeater, the hop is placed only if a candidate is clearly the closest
fit to a known anchor (the bot's own location, the sender, or an
unambiguous neighbouring hop); otherwise it is left unlocated rather than
risk using a far repeater that merely shares the hash. Setting the bot's own
location on the radio improves this disambiguation.
resolve only against repeater/room contacts. A hop matching a single repeater
is always trusted. When a hash matches more than one repeater, the hop is
placed only if the nearest located candidate lies within
`[bot] path_collision_radius_miles` (default 150) of a known anchor — the
bot's own location, the sender, or an unambiguous neighbouring hop —
otherwise it is left unlocated rather than risk a far repeater that merely
shares the hash (e.g. one pulled in by tropospheric ducting). Set the radius
to `0` to disable that bound (collisions then resolve only when ≥2 candidates
are located). Any one anchor enables this — the bot's own location, the
sender, or an unambiguously-located neighbouring hop — so setting the bot's
own location on the radio helps but isn't required.

- 10s per-user cooldown; no auth; works in DM and any allowed channel.
- Makes a da.gd shortener call per invocation (when ≥2 hops are located).
Expand Down
38 changes: 24 additions & 14 deletions commands/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ async def _resolve_hops(ctx, hops):
- only repeater/room contacts are candidates (clients never repeat);
- a hop whose hash matches a single contact is trusted, even if distant
(a unique hash is unambiguous);
- a colliding hop is placed only when >= 2 of its candidates carry a
location and one is the clear nearest to a trusted anchor (the bot's
own location, the sender, or an already-unambiguous hop). A lone
located candidate among a collision is ambiguous and left unlocated,
so a wrong distance is never shown.
- a colliding hop is resolved to the located candidate nearest a trusted
anchor (the bot's own location, the sender, or an already-unambiguous
hop), but ONLY if that candidate is within cfg.path_collision_radius_miles
of the anchor — a single-hop RF sanity bound. This keeps a correctly-
located local hop even when it's the only located candidate, while
rejecting a far repeater that merely shares the hash (tropo ducting).
Radius 0 disables the bound: collisions then resolve only when >= 2
candidates are located (the conservative fallback). With no anchors at
all, collisions stay unlocated.
"""
per_hop = []
for h in hops:
Expand Down Expand Up @@ -131,16 +135,22 @@ async def _resolve_hops(ctx, hops):
anchors.append((located[0][0], located[0][1]))

# pass 2: collisions — choose the located candidate nearest a trusted
# anchor, but only when at least two candidates are located (otherwise the
# right one can't be told apart from the impostor → leave unlocated).
# anchor, accepting it only within the configured radius (a one-hop RF
# sanity bound). radius 0 falls back to "place only when >= 2 located".
radius = ctx.bot.cfg.path_collision_radius_miles

def nearest_anchor_mi(p):
return min(_haversine(p[0], p[1], a[0], a[1], "mi") for a in anchors)

for i, (n, located) in enumerate(per_hop):
if n > 1 and len(located) >= 2 and anchors:
resolved[i] = min(
located,
key=lambda p: min(
_haversine(p[0], p[1], a[0], a[1], "mi") for a in anchors
),
)
if n <= 1 or not located or not anchors:
continue
best = min(located, key=nearest_anchor_mi)
if radius > 0:
if nearest_anchor_mi(best) <= radius:
resolved[i] = best
elif len(located) >= 2:
resolved[i] = best
return resolved


Expand Down
13 changes: 13 additions & 0 deletions mcbot.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ enabled = true
; start, then runtime-managed via '!adm command retry N' or the web
; Manage->Commands page (persists across restarts).
;channel_retry_max = 2
; !path collision disambiguation radius, in MILES. When a path-hop hash matches
; more than one repeater, a candidate is accepted only if it lies within this
; distance of a trusted anchor (the bot's own location, the message sender, or
; an unambiguous neighbouring hop). This rejects a far repeater that merely
; shares the hash (e.g. one pulled in by tropospheric ducting) while keeping a
; correctly-located local hop — even when it is the only located candidate.
; Applies ONLY to hash collisions; a hop matching a single repeater is always
; trusted. Set 0 to disable the bound (collisions then resolve only when >= 2
; candidates are located). Works whenever the path has at least one anchor — the
; bot's own location, the message sender, or an unambiguously-located
; neighbouring hop — so the bot's own location helps but isn't required.
; Default: 150.
;path_collision_radius_miles = 150
; Path to the exported radio private key (0600). Default: <db>.privkey
;privkey_path = ./mcbot.privkey
; Comma-separated full pubkeys bootstrapped into the 'owner' group on every
Expand Down
16 changes: 15 additions & 1 deletion mcbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,17 @@ class Config:
# bot_meta, then the DB value is authoritative and runtime-managed (via
# '!adm command retry N' and the web Manage->Commands page).
channel_retry_max: int = 2
# !path collision disambiguation: when a path-hop hash matches more than one
# repeater, a candidate is accepted only if it lies within this many miles of
# a trusted anchor (the bot's own location, the sender, or an unambiguous
# neighbouring hop). This is a single-hop RF sanity bound that rejects a far
# repeater sharing the hash (e.g. a tropospheric-ducting node) while keeping
# a correctly-located local hop. Applies ONLY to collisions; unique hops are
# always trusted. 0 disables it (collisions then resolve only when >= 2
# candidates are located). Takes effect whenever the path has at least one
# anchor (any of the three above); the bot's own location helps but is not
# required if a neighbouring hop resolves unambiguously.
path_collision_radius_miles: float = 150.0
debug: bool = False
# idx -> (name, 16-byte secret)
channels: dict[int, tuple[str, bytes]] = field(default_factory=dict)
Expand Down Expand Up @@ -480,7 +491,7 @@ def target_desc(self) -> str:
"channel_logging": {"channels"},
"logging": {"logs_dir", "log_level"},
"bot": {"commands_dir", "enabled", "repeat_tracking", "repeat_timeout",
"channel_retry_max",
"channel_retry_max", "path_collision_radius_miles",
"privkey_path", "dm_max_attempts", "dm_flood_after",
"dm_max_flood_attempts", "radio_evict_enabled",
"radio_evict_headroom", "radio_evict_max_per_run",
Expand Down Expand Up @@ -583,6 +594,9 @@ def load_config(args) -> Config:
cfg.channel_retry_max = min(5, max(0, parser["bot"].getint(
"channel_retry_max", cfg.channel_retry_max
)))
cfg.path_collision_radius_miles = max(0.0, parser["bot"].getfloat(
"path_collision_radius_miles", cfg.path_collision_radius_miles
))
pk = parser["bot"].get("privkey_path", "")
if pk:
cfg.privkey_path = Path(pk)
Expand Down
70 changes: 70 additions & 0 deletions tests/test_path_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,72 @@ async def test_sender_anchor_used_when_no_bot_location():
bot.db.close()


async def test_collision_lone_local_within_radius_accepted():
print("test_collision_lone_local_within_radius_accepted")
bot = make_bot()
await set_bot_location(bot, 32.0, -96.0)
# collision where the only located candidate is the correct LOCAL hop (a
# few miles from the bot); a geoless repeater shares the hash. Within the
# radius it must be kept (the regression the radius restores).
await add_contact(bot, pk("abcd0"), name="local", lat=32.05, lon=-96.05)
await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None)
res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"])
check(res[0] is not None and res[0][2] == "local",
f"lone local hop within radius is kept (got {res[0]})")
bot.db.close()


async def test_collision_radius_gates_acceptance():
print("test_collision_radius_gates_acceptance")
bot = make_bot()
await set_bot_location(bot, 32.0, -96.0)
# lone located candidate ~69 mi north of the bot, colliding with a geoless one
await add_contact(bot, pk("abcd0"), name="hop", lat=33.0, lon=-96.0)
await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None)

bot.cfg.path_collision_radius_miles = 150
res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"])
check(res[0] is not None and res[0][2] == "hop", "within 150mi radius -> accepted")

bot.cfg.path_collision_radius_miles = 50
res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"])
check(res == [None], "beyond 50mi radius -> rejected")

bot.cfg.path_collision_radius_miles = 0
res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"])
check(res == [None], "radius 0 -> lone candidate never trusted (conservative)")
bot.db.close()


async def test_neighbour_hop_anchors_without_bot_or_sender():
print("test_neighbour_hop_anchors_without_bot_or_sender")
bot = make_bot() # no bot location, no sender
# hop 1 matches a single located repeater -> resolves and becomes an anchor
await add_contact(bot, pk("aaaa"), name="anchorhop", lat=32.0, lon=-96.0)
# hop 2 collides: a nearby located hop + a geoless one. The only anchor is
# the neighbouring hop, which must be enough to keep the local candidate.
await add_contact(bot, pk("abcd0"), name="near", lat=32.1, lon=-96.1)
await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None)
res = await pathcmd._resolve_hops(ctx_for(bot), ["aaaa", "abcd"])
check(res[0] is not None and res[0][2] == "anchorhop", "unambiguous hop resolved")
check(res[1] is not None and res[1][2] == "near",
"neighbouring located hop anchors the collision (no bot/sender loc)")
bot.db.close()


async def test_radius_zero_still_disambiguates_two_located():
print("test_radius_zero_still_disambiguates_two_located")
bot = make_bot()
await set_bot_location(bot, 32.0, -96.0)
await add_contact(bot, pk("abcd0"), name="near", lat=32.1, lon=-96.1)
await add_contact(bot, pk("abcd1"), name="far", lat=30.0, lon=-92.0)
bot.cfg.path_collision_radius_miles = 0
res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"])
check(res[0] is not None and res[0][2] == "near",
"radius 0 still picks nearest when >=2 located")
bot.db.close()


async def main():
for t in (
test_collision_only_far_has_geo_is_unlocated,
Expand All @@ -157,6 +223,10 @@ async def main():
test_non_repeater_excluded,
test_unique_geoless_hop_is_unlocated,
test_sender_anchor_used_when_no_bot_location,
test_collision_lone_local_within_radius_accepted,
test_collision_radius_gates_acceptance,
test_neighbour_hop_anchors_without_bot_or_sender,
test_radius_zero_still_disambiguates_two_located,
):
await t()
print()
Expand Down
Loading