fix(response_template,url_shortener): follow-ups to PR #254

Integration fixes on top of the merge, in three groups.

Regressions in the shared shortener, which is not opt-in — weather alerts
call it unconditionally whenever an alert carries a link:

- Restore the `response.ok` guard on the v.gd path. Without it a 503 whose
  body happens to start with `http` (a captive portal, a CDN error page) was
  returned as the short URL. The test that covered this still passed because
  it never set `.text`, so `MagicMock.startswith()` answered truthily; it now
  supplies a body that would be accepted on a 200, so only the status check
  can make it pass.
- Add the same guard to the shlink path, where a rejected API key is a 401
  with a problem-details body and was indistinguishable from an empty result.
- Restore debug-level logging for `Timeout`/`ConnectionError`. A mesh node's
  uplink drops out routinely and this had become `logger.error`.
- Drop the `shortUrlSlug` fallback. Shlink's create response carries
  `shortUrl` and `shortCode`; `shortUrlSlug` is not in its schema, and a bare
  slug is not a link — emitting one would have put `abc123` in a message.

The `shorten_url` filter did blocking HTTP inside the synchronous render
path (`process_message` → `check_keywords` → `format_response`), so a 5 s
shortener timeout stalled the radio transport along with everything else.
Making `check_keywords` async would touch 27 call sites, so instead the
network work moves ahead of the render:

- `resolve_template_async()` renders once with the filter in collection mode,
  which walks the real chain so a clause already suppressed by `hops_min`
  costs no request, then shortens what survived concurrently off-thread.
- The filter itself now only reads that mapping and never calls out. Asked to
  render without a pre-pass it warns and drops the clause rather than block.
- On failure the clause is dropped rather than sent unshortened. A v.gd link
  is ~19 bytes where the analyzer URL is ~59, against a ~158-byte budget the
  prefix is subtracted from, so falling back would have turned one
  transmission into two whenever the shortener was unreachable.

Smaller: rename `if_notempty` to `if_nonempty` to match the existing
`prefix_if_nonempty` (nothing ships using it yet, so there is no config to
migrate); `_build_create_shlink_url` no longer takes the long URL and API key
it never used; move the CHANGELOG entry from Fixed to Added and name the
filter it actually added; ungate the render debug log from `config`; document
that `shorten_url` is supported in `path`'s `reply_prefix` only.

Every template shipped in config.ini.example still renders identically to the
regex parser it replaced.
This commit is contained in:
Adam Gessaman committed 2026-08-29 14:48:31 -07:00
1 parent ec6b9cbfc0
commit 7fbfb07a85
8 files changed
+324 -62

No files matched your search

+17 -5
View File
@@ -15,7 +15,7 @@ from ..path_inference import (
select_node_repeater,
select_repeater_by_graph,
)
from ..response_template import format_piped_template
from ..response_template import format_piped_template, resolve_template_async
from ..utils import (
bytes_per_hop_from_routing_and_nodes,
calculate_distance,
@@ -415,18 +415,30 @@ class PathCommand(BaseCommand):
return ''
return f"{distance:.1f}km"
def _format_path_reply_prefix(self, message: MeshMessage) -> str:
async def _format_path_reply_prefix(self, message: MeshMessage) -> str:
if not self.path_reply_prefix:
return ''
fields = self.get_standard_placeholder_fields(message)
fields['path_distance'] = self._format_path_distance(message)
formatted = format_piped_template(
str_fields = {k: str(v) for k, v in fields.items()}
# Any URL shortening happens here, off the event loop, before the
# synchronous render runs. See modules.response_template.
shortened = await resolve_template_async(
self.path_reply_prefix,
{k: str(v) for k, v in fields.items()},
str_fields,
message=message,
logger=self.logger,
config=self.bot.config,
prefix_hex_chars=getattr(self.bot, 'prefix_hex_chars', 2),
)
formatted = format_piped_template(
self.path_reply_prefix,
str_fields,
message=message,
logger=self.logger,
config=self.bot.config,
shortened=shortened,
prefix_hex_chars=getattr(self.bot, 'prefix_hex_chars', 2),
).rstrip()
if not formatted:
return ''
@@ -1057,7 +1069,7 @@ class PathCommand(BaseCommand):
async def _send_path_response(self, message: MeshMessage, response: str):
"""Send path response, splitting into multiple messages if necessary"""
prefix = self._format_path_reply_prefix(message)
prefix = await self._format_path_reply_prefix(message)
self.last_response = prefix + response if prefix else response
max_length = self.get_max_message_length(message)