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
63 changes: 59 additions & 4 deletions Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ CLI: `--logs-dir`, `--log-level`, `--debug` (sets meshcore lib to DEBUG)
| `dm_max_attempts` | 3 | `send_msg_with_retry` max attempts |
| `dm_flood_after` | 2 | Switch to flood routing after N direct attempts |
| `dm_max_flood_attempts` | 2 | Max flood-mode retries |
| `url_shortener` | `dagd` | `dagd`, `sink`, or `none` -- see [URL shortening](#url-shortening) |
| `sink_api_base` | (empty) | Sink instance base URL, e.g. `https://sht.nz`. Required when `url_shortener = sink` |
| `sink_link_ttl_days` | 30 | Days before a bot-created Sink link expires; `0` keeps them forever |

CLI: `--commands-dir`, `--privkey-path`, `--disable-commands`,
`--dm-max-attempts`, `--dm-flood-after`, `--dm-max-flood-attempts`,
Expand All @@ -337,6 +340,53 @@ CLI: `--commands-dir`, `--privkey-path`, `--disable-commands`,
To re-seed from conf: `sqlite3 mcbot.db "DELETE FROM channels;"` then
restart.

### URL shortening

`!path` and `!topo` reply with map links. A mesh message has ~180 characters
to spend and a geojson.io map payload alone runs to several KB, so the link
has to be shortened by a service. `[bot] url_shortener` picks which:

| Value | Behaviour |
|-------|-----------|
| `dagd` | Public [da.gd](https://da.gd) service. No account, no configuration. Default. |
| `sink` | Your own [Sink](https://github.com/miantiao-me/Sink) instance. Needs `sink_api_base` and a `SINK_API_TOKEN`. |
| `none` | No shortening. `!topo` sends the raw OpenTopoMap URL; `!path` replies `(map err)`. |

For `sink`, put the token in `[env]` rather than a command script -- the conf
file is gitignored, `commands/*.py` are tracked:

```ini
[bot]
url_shortener = sink
sink_api_base = https://sht.nz
sink_link_ttl_days = 30

[env]
SINK_API_TOKEN = <your NUXT_SITE_TOKEN>
```

The token is Sink's `NUXT_SITE_TOKEN` (its API bearer credential), *not* the
`NUXT_CF_API_TOKEN` analytics token. Notes on the `sink` provider:

- Links are created with `POST /api/link/create` and no slug, so Sink
assigns its own -- 6 characters by default. Every character matters in a
mesh message, which is why the bot doesn't supply a longer deterministic
slug to make repeat routes share a link; `sink_link_ttl_days` keeps the
table from growing without bound instead. To go shorter still, set
`NUXT_PUBLIC_SLUG_DEFAULT_LENGTH` on the Sink instance.
- **It falls back to da.gd** when Sink is unreachable, misconfigured, or
rejects the URL, so a route never silently loses its map. The one limit
worth knowing is the target-URL cap each instance enforces: stock Sink
allows 2048 characters, which a route past ~8 hops exceeds. It is a
one-line change -- `UrlSchema` in `shared/schemas/link.ts` -- and at 8192
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.
- Clicks on bot-generated links land in your Sink analytics.

Changing any of these needs a bot restart -- `!adm reload` re-executes
command plugins but not the shared `shortener` module.

---

## 4. Running the Bot
Expand Down Expand Up @@ -934,8 +984,9 @@ in one reply. Arguments:
(gaps are bridged, so route ≥ direct). **direct** = great-circle distance
between the first and last located hop. 0.1 resolution. `mi` = miles, `km` = km.
- The map is a [geojson.io](https://geojson.io) link drawing a LineString
through the located hops plus a labeled marker per hop, shortened via da.gd
so the reply fits a mesh message. Open it in any browser with internet.
through the located hops plus a labeled marker per hop, shortened so the
reply fits a mesh message (see [URL shortening](#url-shortening)). Open it
in any browser with internet.
- If some hops can't be located, a `(located/total)` count is shown and the
distances/map use only the located hops:
```
Expand All @@ -960,12 +1011,16 @@ in one reply. Arguments:
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).
- Makes one shortener call per invocation (when ≥1 hop is located). The
geojson payload is several KB, so with shortening off or unreachable the
reply ends in `(map err)` -- there is no raw URL short enough to send.

### `!topo` / `!topo <prefix>` / `!topo help`

Plots a contact's advertised location on [OpenTopoMap](https://opentopomap.org)
and replies with a da.gd-shortened marker link (zoom 16).
and replies with a shortened marker link (zoom 16). Unlike `!path`, the raw
OpenTopoMap URL is short enough to send, so it is used as-is if shortening
is off or unreachable.

```
!topo a1b2c3 → @[Alice] HillTop https://da.gd/abcd
Expand Down
30 changes: 9 additions & 21 deletions commands/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
# - <n>h hop count; <hops> are per-hop pubkey-prefix hashes.
# - route great-circle distance summed between consecutive located hops.
# - direct great-circle distance between the first and last located hop.
# - <url> da.gd shortened geojson.io map drawing the route.
# - <url> shortened geojson.io map drawing the route. which shortener
# is used is set by [bot] url_shortener in mcbot.conf.
# - distances are miles by default. pass 'k' for kilometers: !path k
# - if some hops can't be located, a (located/total) count is shown and the
# distances/map use only the located hops.
Expand All @@ -24,7 +25,7 @@
import json
import math

import requests
from shortener import shorten

NAME = "path"
TRIGGERS = ["!path"]
Expand Down Expand Up @@ -201,19 +202,6 @@ def _geojson_io_url(located_named):
return "https://geojson.io/#data=data:application/json;base64," + b64


def _shorten_sync(long_url):
# shorten via da.gd
#
r = requests.get(
"https://da.gd/s", params={"url": long_url}, timeout=8,
)
r.raise_for_status()
s = r.text.strip()
if not s.startswith("http"):
raise ValueError(f"shortener error: {s[:60]}")
return s


def _parse_path_arg(path_arg):
# parse provided path string argument into path_hex, path_len,
# hash_mode. returns an error string on bad input.
Expand Down Expand Up @@ -294,12 +282,12 @@ async def handle(ctx):
if n_located == 0:
return f"@[{name}] [{nh}h] {path_str} dist/map unavailable (0/{nh})"

# map link for any 1 or more located hops
try:
short = await asyncio.to_thread(_shorten_sync, _geojson_io_url(loc))
except Exception:
ctx.bot.logger.exception("path map: shorten failed")
short = None
# map link for any 1 or more located hops. the geojson payload is far too
# 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",
)
map_part = f", {short}" if short else " (map err)"

# one located hop can't compute a distance, but still map the pin.
Expand Down
25 changes: 7 additions & 18 deletions commands/topo.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# !topo — plot a contact's location on OpenTopoMap (with a da.gd short link)
# !topo — plot a contact's location on OpenTopoMap (with a short link)
#
# usage:
# !topo <pubkey-prefix> plot that contact's location (prefix 4+ hex chars)
Expand All @@ -12,7 +12,7 @@

import asyncio

import requests
from shortener import shorten

NAME = "topo"
TRIGGERS = ["!topo"]
Expand All @@ -26,16 +26,6 @@
_USAGE = "!topo [pub-key prefix] (4+ chars)"


def _shorten_sync(long_url):
# shorten via da.gd (same service !path uses)
r = requests.get("https://da.gd/s", params={"url": long_url}, timeout=8)
r.raise_for_status()
s = r.text.strip()
if not s.startswith("http"):
raise ValueError(f"shortener error: {s[:60]}")
return s


def _topo_url(lat, lon):
# OpenTopoMap marker link at zoom 16
return f"https://opentopomap.org/#marker=16/{lat:.5f}/{lon:.5f}"
Expand All @@ -49,13 +39,12 @@ def _has_geo(row):


async def _map_reply(ctx, name, contact_name, lat, lon):
# build the OpenTopoMap link and shorten it; if da.gd is unreachable fall
# back to the full URL (short enough to send) rather than failing.
# 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)
try:
url = await asyncio.to_thread(_shorten_sync, url)
except Exception:
ctx.bot.logger.exception("topo: shorten failed")
url = await asyncio.to_thread(
shorten, url, ctx.bot.cfg, ctx.bot.logger, "topo",
) or url
label = (contact_name or "").strip() or "(no name)"
return f"@[{name}] {label} {url}"

Expand Down
35 changes: 34 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
from typing import Optional

import shortener
from protocol import parse_contact_types

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -71,6 +72,17 @@ class Config:
# 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
# --- map-link shortening (!path, !topo) ---
# 'dagd' (public da.gd), 'sink' (self-hosted Sink instance), or 'none'.
# 'sink' needs sink_api_base plus a SINK_API_TOKEN env var (set it in
# [env]; this file is gitignored, command scripts are not) and falls back
# to da.gd when Sink is unreachable or rejects the URL as too long.
url_shortener: str = "dagd"
sink_api_base: str = ""
# days until a bot-created Sink link expires; 0 keeps links forever.
# Map links are single-use in practice, and the slug is derived from the
# URL, so an expired route is simply re-created on the next request.
sink_link_ttl_days: int = 30
debug: bool = False
# idx -> (name, 16-byte secret)
channels: dict[int, tuple[str, bytes]] = field(default_factory=dict)
Expand Down Expand Up @@ -149,7 +161,8 @@ def target_desc(self) -> str:
"radio_evict_headroom", "radio_evict_max_per_run",
"radio_evict_min_interval", "radio_evict_protect_types",
"advert_interval_hours", "command_delay", "watchdog_interval",
"owner_pubkeys"},
"owner_pubkeys", "url_shortener", "sink_api_base",
"sink_link_ttl_days"},
"web": {"enabled", "host", "port", "admin_user", "admin_password_hash",
"session_secret", "cors_origins", "api_tokens", "tls_cert",
"tls_key"},
Expand Down Expand Up @@ -250,6 +263,26 @@ def load_config(args) -> Config:
cfg.path_collision_radius_miles = max(0.0, parser["bot"].getfloat(
"path_collision_radius_miles", cfg.path_collision_radius_miles
))
cfg.url_shortener = parser["bot"].get(
"url_shortener", cfg.url_shortener
).strip().lower()
if cfg.url_shortener not in shortener.PROVIDERS:
sys.stderr.write(
f"ERROR: url_shortener must be one of "
f"{', '.join(shortener.PROVIDERS)}\n"
)
sys.exit(2)
cfg.sink_api_base = parser["bot"].get(
"sink_api_base", cfg.sink_api_base
).strip()
cfg.sink_link_ttl_days = max(0, parser["bot"].getint(
"sink_link_ttl_days", cfg.sink_link_ttl_days
))
if cfg.url_shortener == "sink" and not cfg.sink_api_base:
sys.stderr.write(
"ERROR: url_shortener = sink needs sink_api_base\n"
)
sys.exit(2)
pk = parser["bot"].get("privkey_path", "")
if pk:
cfg.privkey_path = Path(pk)
Expand Down
19 changes: 19 additions & 0 deletions mcbot.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ enabled = true
; Default: 150.
;path_collision_radius_miles = 150

; Which service shortens the map links in !path and !topo replies. A mesh
; message has ~180 characters and a geojson.io map payload is several KB, so
; !path has no reply to give without one.
; dagd public da.gd service; no account or configuration (default)
; sink your own Sink instance; needs sink_api_base + SINK_API_TOKEN below.
; Sink assigns each link its own 6-character slug. Falls back to da.gd
; when Sink is unreachable or rejects the URL -- e.g. for exceeding
; the instance's target-URL cap (UrlSchema in shared/schemas/link.ts;
; stock Sink allows 2048 characters, which a route past ~8 hops
; exceeds; at 8192 no real path comes close).
; none no shortening: !topo sends the raw URL, !path replies '(map err)'
;url_shortener = dagd
;sink_api_base = https://sht.nz
; Days until a bot-created Sink link expires; 0 keeps them forever.
;sink_link_ttl_days = 30

; 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 Expand Up @@ -174,6 +190,9 @@ owner_pubkeys = 0000000000000000000000000000000000000000000000000000000000000000
[env]
;PWS_API_KEY = your-weather-com-api-key
;PWS_STATION_ID = KXXYYYY1234
; Sink API bearer token, used when [bot] url_shortener = sink. This is the
; instance's NUXT_SITE_TOKEN, not its NUXT_CF_API_TOKEN analytics token.
;SINK_API_TOKEN = your-sink-site-token

[web]
; Optional web admin UI + REST/WebSocket API, run in-process. Disabled by
Expand Down
Loading
Loading