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
8 changes: 8 additions & 0 deletions Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,14 @@ The token is Sink's `NUXT_SITE_TOKEN` (its API bearer credential), *not* the
a route would have to exceed ~30 hops to hit it, which no real mesh path
does. Check yours before assuming long routes reach Sink rather than the
fallback.
- Each link is tagged `mcbot`, the command (`path`, `topo`), and
`from:<sender>` -- the same name the reply is addressed to, so the Sink
dashboard's tag filter shows who generated a map. For `!topo` that is the
sender, not the contact being plotted. The prefix keeps a sender named
like a command distinct, since Sink
lowercases and de-duplicates tags. Names are trimmed to fit Sink's
32-character tag limit, which it counts in UTF-16 units (an emoji costs
two) and enforces by rejecting the whole request.
- Clicks on bot-generated links land in your Sink analytics.

Changing any of these needs a bot restart -- `!adm reload` re-executes
Expand Down
2 changes: 1 addition & 1 deletion commands/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ async def handle(ctx):
# long to send raw, so without a shortener there is no map to offer.
short = await asyncio.to_thread(
shorten, _geojson_io_url(loc), ctx.bot.cfg,
ctx.bot.logger, "path",
ctx.bot.logger, "path", name,
)
map_part = f", {short}" if short else " (map err)"

Expand Down
4 changes: 3 additions & 1 deletion commands/topo.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ async def _map_reply(ctx, name, contact_name, lat, lon):
# build the OpenTopoMap link and shorten it; if no shortener is configured
# or reachable, fall back to the full URL (short enough to send).
url = _topo_url(lat, lon)
# `name` is the sender, not the contact being plotted -- the tag records
# who asked, matching !path.
url = await asyncio.to_thread(
shorten, url, ctx.bot.cfg, ctx.bot.logger, "topo",
shorten, url, ctx.bot.cfg, ctx.bot.logger, "topo", name,
) or url
label = (contact_name or "").strip() or "(no name)"
return f"@[{name}] {label} {url}"
Expand Down
43 changes: 39 additions & 4 deletions shortener.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@
_DAGD_URL = "https://da.gd/s"
_TIMEOUT = 8
_SINK_TOKEN_ENV = "SINK_API_TOKEN"
_SINK_TAG_MAX = 32
_SINK_SENDER_PREFIX = "from:"


def _fit_utf16(text, limit):
# Sink validates tag length in UTF-16 code units (zod counts JS string
# .length), where anything astral -- an emoji in a node name -- costs 2
# against Python's 1. Truncating per code point never splits a pair.
out, used = [], 0
for ch in text:
n = 2 if ord(ch) > 0xFFFF else 1
if used + n > limit:
break
out.append(ch)
used += n
return "".join(out)


def _sender_tag(sender):
# 'from:<name>', so a sender named e.g. "path" stays distinct from the
# command tag (Sink lowercases and de-duplicates tags, which would
# otherwise silently merge the two). None when there's no name to use.
name = (sender or "").strip()
if not name:
return None
fitted = _fit_utf16(name, _SINK_TAG_MAX - len(_SINK_SENDER_PREFIX))
return _SINK_SENDER_PREFIX + fitted if fitted else None


def _shorten_dagd(long_url):
Expand All @@ -33,16 +60,21 @@ def _shorten_dagd(long_url):
return s


def _shorten_sink(long_url, base, token, ttl_days, tag):
def _shorten_sink(long_url, base, token, ttl_days, tag, sender):
# No slug: Sink generates its own, which is 6 characters by default
# (slugDefaultLength) against a 30-character alphabet. Every character
# counts in a ~180-character mesh message, so that beats a longer
# deterministic slug that would let repeat routes share one link --
# sink_link_ttl_days does the housekeeping instead.
payload = {"url": long_url}
tags = ["mcbot"]
if tag:
payload["tags"] = ["mcbot", tag]
tags.append(tag)
payload["comment"] = f"mcbot !{tag}"
sender_tag = _sender_tag(sender)
if sender_tag:
tags.append(sender_tag)
payload["tags"] = tags
if ttl_days > 0:
payload["expiration"] = int(time.time()) + int(ttl_days) * 86400
# create, not upsert: on the (remote) chance Sink's generated slug is
Expand All @@ -64,9 +96,12 @@ def _shorten_sink(long_url, base, token, ttl_days, tag):
return str(short)


def shorten(long_url, cfg, logger=None, tag=""):
def shorten(long_url, cfg, logger=None, tag="", sender=""):
"""Return a shortened URL, or None if none could be produced.

`tag` and `sender` label the link in the Sink dashboard (as `<tag>` and
`from:<sender>`) and are ignored by the other providers.

Blocking (requests), so call it via asyncio.to_thread. Never raises:
every caller has its own idea of what an unshortened link means for its
reply, and none of them can send a multi-KB URL over the mesh.
Expand All @@ -82,7 +117,7 @@ def shorten(long_url, cfg, logger=None, tag=""):
try:
return _shorten_sink(
long_url, base, token,
getattr(cfg, "sink_link_ttl_days", 30), tag,
getattr(cfg, "sink_link_ttl_days", 30), tag, sender,
)
except Exception:
if logger:
Expand Down
41 changes: 41 additions & 0 deletions tests/test_shortener.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,47 @@ def test_sink_create_request(net, sink_token):
assert not net.calls["get"], "no da.gd call when sink succeeds"


def tags_from(net, sender, tag="path"):
net.scripted["post"] = FakeResponse(payload={"shortLink": "https://sht.nz/x"})
shortener.shorten(LONG, cfg_for("sink"), tag=tag, sender=sender)
return net.calls["post"][-1]["json"]["tags"]


def test_sender_is_tagged(net, sink_token):
assert tags_from(net, "Alice") == ["mcbot", "path", "from:Alice"], \
"sender attributed on the link"


def test_sender_tag_is_prefixed_against_collisions(net, sink_token):
# Sink lowercases and de-duplicates tags, so a bare "Path" sender tag
# would silently merge into the command tag and lose the attribution
assert tags_from(net, "Path") == ["mcbot", "path", "from:Path"], \
"prefix keeps a sender named like the command distinct"


def test_missing_sender_adds_no_tag(net, sink_token):
for empty in ("", " ", None):
assert tags_from(net, empty) == ["mcbot", "path"], \
f"no from: tag for {empty!r}"


def test_long_sender_is_truncated(net, sink_token):
# Sink rejects the whole create if any tag exceeds 32, which would drop
# the link to the da.gd fallback rather than just losing the tag
tags = tags_from(net, "A" * 60)
assert len(tags[-1]) == 32, f"tag fitted to the limit (got {len(tags[-1])})"
assert tags[-1].startswith("from:AAAA")


def test_emoji_sender_measured_in_utf16_units(net, sink_token):
# Sink counts tag length the way JS does: an astral char costs 2 units,
# so 27 emoji would be 54 units and reject the request outright
tags = tags_from(net, "\U0001F680" * 27)
units = len(tags[-1].encode("utf-16-le")) // 2
assert units <= 32, f"tag is {units} UTF-16 units, over Sink's limit"
assert tags[-1] == "from:" + "\U0001F680" * 13, "13 emoji = 26 units + 5"


def test_no_slug_is_sent(net, sink_token):
# Sink's own generated slug is 6 characters; supplying one of our own
# would only make the reply longer, and a mesh message has ~180 to spend.
Expand Down
6 changes: 5 additions & 1 deletion tests/test_topo.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
_captured = {}


def _fake_shorten(url, cfg, logger=None, tag=""):
def _fake_shorten(url, cfg, logger=None, tag="", sender=""):
_captured["url"] = url
_captured["tag"] = tag
_captured["sender"] = sender
return "https://da.gd/test"


Expand Down Expand Up @@ -68,6 +69,9 @@ async def test_single_match_with_geo(bot_factory):
assert _captured.get("url") == \
"https://opentopomap.org/#marker=16/30.31023/-97.84505", \
"OpenTopoMap url passed to shortener"
# the tag records who asked, not the contact that was plotted
assert _captured.get("sender") == "bob", "sender passed for the from: tag"
assert _captured.get("tag") == "topo", "tagged as the topo command"


async def test_single_match_no_geo(bot_factory):
Expand Down
Loading