Files
meshcore-bot/modules/response_template.py
T
agessaman 7debb92e9e feat(template): add a hops_min filter for gating on route length
{firstlast_distance|prefix_if_nonempty: | F/L Dist: } prints "| F/L Dist: N/A"
on a direct message: the distance helpers return the literal "N/A" when there
is no path to measure, prefix_if_nonempty only asks whether the value is
non-empty, and "N/A" is. Suppressing that needed a gate, and pathbytes_min was
the only one available—so it was being used as a stand-in for "did this
message take any hops", which is not what it asks. It asks how the path is
encoded, so pathbytes_min:2 also discards a one-byte multi-hop path whose
distance is real and measurable.

hops_min:N asks about the route itself. hops_min:1 drops a clause on a direct
message and nothing else:

    hops_min:1   pathbytes_min:2
    direct         cleared      cleared
    1-byte 2 hops  kept         cleared   <- the difference
    2-byte 2 hops  kept         kept
    unknown        cleared      cleared

An unknown hop count clears the value: a gate that cannot confirm the route
should suppress rather than guess, matching pathbytes_min.

The hop-count logic moves to utils.message_hop_count so the filter and
BaseCommand.get_hops_display_values share one implementation instead of two.
It returns None for unknown rather than 0, since a gate must not read
"cannot tell" as "direct".
2026-08-25 10:03:30 -07:00

157 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Piped placeholders for command response templates (feed-style ``{field|filter:args}``).
Used by :class:`~modules.commands.test_command.TestCommand` and extensible for other
commands. Same brace limitation as feed formatting: no nested ``{}`` inside a placeholder.
"""
from __future__ import annotations
import re
from typing import Any, Callable
from .utils import message_hop_count, message_path_bytes_per_hop
FilterFn = Callable[[str, dict[str, Any], str], str]
def _filter_pathbytes_min(value: str, ctx: dict[str, Any], args: str) -> str:
"""Clear *value* unless message path uses at least *N* bytes per hop (N in 1..3)."""
message = ctx.get('message')
if message is None:
return ''
try:
n = int(args.strip())
except ValueError:
return value
if n < 1 or n > 3:
return value
prefix_hex = int(ctx.get('prefix_hex_chars') or 2)
bph = message_path_bytes_per_hop(message, prefix_hex_chars=prefix_hex)
if bph < n:
return ''
return value
def _filter_hops_min(value: str, ctx: dict[str, Any], args: str) -> str:
"""Clear *value* unless the message travelled at least *N* hops.
Asks about the route rather than how it is encoded, which is what separates
this from ``pathbytes_min``: a one-byte multi-hop path has a real, measurable
distance, and ``pathbytes_min:2`` would throw it away along with the direct
messages it was aimed at. ``hops_min:1`` is the way to drop a clause on a
direct message and nothing else.
An unknown hop count clears the value: a gate that cannot confirm the route
should suppress rather than guess, matching ``pathbytes_min``.
"""
message = ctx.get('message')
if message is None:
return ''
try:
n = int(args.strip())
except ValueError:
return value
if n < 0:
return value
hops = message_hop_count(message)
if hops is None or hops < n:
return ''
return value
def _filter_prefix_if_nonempty(value: str, ctx: dict[str, Any], args: str) -> str:
"""Prepend *args* literal to *value* only when *value* is non-empty after prior filters."""
if not value:
return ''
return args + value
RESPONSE_TEMPLATE_FILTERS: dict[str, FilterFn] = {
'pathbytes_min': _filter_pathbytes_min,
'pathbytes': _filter_pathbytes_min,
'hops_min': _filter_hops_min,
'prefix_if_nonempty': _filter_prefix_if_nonempty,
}
def _field_and_filter_specs(inner: str) -> tuple[str, list[tuple[str, str]]]:
"""Split ``inner`` into field name and ``(filter_name, args)`` pairs.
Pipe ``|`` separates filters. ``prefix_if_nonempty`` is special: its argument may
contain ``|`` (e.g. `` | Path Dist: ``), so once that filter is reached we merge
all remaining segments and treat the rest as its args. ``prefix_if_nonempty`` must
be last in the chain if the literal includes a pipe.
"""
raw_parts = inner.split('|')
field_name = raw_parts[0].strip()
if len(raw_parts) < 2:
return field_name, []
specs: list[tuple[str, str]] = []
i = 1
while i < len(raw_parts):
if raw_parts[i].lstrip().startswith('prefix_if_nonempty'):
merged = '|'.join(raw_parts[i:])
if re.match(r'^\s*prefix_if_nonempty\s*$', merged):
specs.append(('prefix_if_nonempty', ''))
break
m = re.match(r'^\s*prefix_if_nonempty\s*:(.*)$', merged, flags=re.DOTALL)
if m:
specs.append(('prefix_if_nonempty', m.group(1)))
else:
specs.append(('prefix_if_nonempty', ''))
break
segment = raw_parts[i].strip()
name, sep, arg = segment.partition(':')
name = name.strip()
arg = arg if sep else ''
specs.append((name, arg))
i += 1
return field_name, specs
def format_piped_template(
template: str,
fields: dict[str, Any],
*,
message: Any = None,
logger: Any = None,
prefix_hex_chars: int = 2,
) -> str:
"""Replace ``{field}`` and ``{field|filter:arg|...}`` using *fields* and optional *message*.
Args:
template: Raw template string from config.
fields: Mapping of placeholder names to values (e.g. ``sender``, ``path_distance``).
An unavailable field is an empty string, which renders as nothing and
lets ``prefix_if_nonempty`` drop its literal label too.
message: Triggering mesh message; required for ``pathbytes`` / ``pathbytes_min`` filters.
logger: Optional logger for unknown filter warnings.
prefix_hex_chars: Bot prefix width for inferring bytes per hop from legacy path text.
Returns:
Fully expanded string.
"""
ctx: dict[str, Any] = {
'message': message,
'logger': logger,
'prefix_hex_chars': prefix_hex_chars,
}
def replace_placeholder(match: re.Match[str]) -> str:
inner_raw = match.group(1)
if '|' not in inner_raw:
return str(fields.get(inner_raw.strip(), ''))
field_name, filter_specs = _field_and_filter_specs(inner_raw)
value = str(fields.get(field_name, ''))
for name, arg in filter_specs:
fn = RESPONSE_TEMPLATE_FILTERS.get(name)
if fn is None:
if logger is not None:
logger.warning(f"Unknown response template filter {name!r} in {{{inner_raw}}}")
continue
value = fn(value, ctx, arg)
return value
return re.sub(r"\{([^}]+)\}", replace_placeholder, template)