mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
This commit is contained in:
14 files changed
+665
-301
No files matched your search
@@ -9,6 +9,10 @@
|
||||
* Web: the federation table renders a Reticulum zero count as a dash instead of a tile (SPEC RD8)
|
||||
|
||||
### Fixes
|
||||
* Web: MeshCore `@[Name]` mentions and replies no longer create, refresh, or revive nodes (#883)
|
||||
* Web: a MeshCore channel message stays on its sender's node and no longer moves to, or revives, a same-name node; a sender named by a stale roster is credited to the current key (#883)
|
||||
* Data: the MeshCore ingestor no longer posts placeholder nodes for chat senders or mentions, so hidden-channel names stay hidden; the web app ignores such posts from older ingestors (#883)
|
||||
* Data: a MeshCore sender name shared by several roster contacts resolves to the most recently advertised one (#883)
|
||||
* Matrix: the bridge crate declares its crates.io package metadata, and its README screenshot no longer links outside the crate (#879)
|
||||
* Data: `TRANSPORT=udp` listens on both Meshtastic multicast groups by default, `239.0.0.69` (firmware 2.8+) and `224.0.0.69`; `MESH_UDP_GROUP` takes a comma-separated list. If you set `MESH_UDP_GROUP=224.0.0.69` before, remove it to also hear firmware 2.8+ nodes (#903)
|
||||
* Data: `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` reaches every packaged deployment - `docker-compose.yml`, both `data/Dockerfile` stages and the NixOS module (`meshcoreTelemetryPoll24hExempt`); setting it in `.env` had no effect under Compose or NixOS (SPEC PX5)
|
||||
|
||||
@@ -154,6 +154,8 @@ MeshCore roster-eviction assertion (SPEC RF4). At startup the provider asserts t
|
||||
|
||||
New protocols SHOULD likewise treat "node was heard" as a first-class, name-optional upsert so peer discovery does not hinge on a roster being populated.
|
||||
|
||||
MeshCore chat placeholders (SPEC GN1/GN4). A MeshCore channel message names its sender only in its `SenderName: body` text. When the ingestor's roster has no contact of that name, the message carries a name-derived `from_id` and no node is POSTed for it: the web app creates the placeholder node itself when it ingests the message, flagged synthetic, and merges it into the real node once that node's contact advertisement arrives. A node entry whose `user.synthetic` is set (any truthy value, the same rule `upsert_node` applies to the flag) is therefore ignored and the request still answers 201, so a POSTed placeholder never records a reception. An `@[Name]` mention or reply prefix is not a reception and never creates, refreshes, or merges a node.
|
||||
|
||||
#### `POST /api/messages`
|
||||
|
||||
Single message payload:
|
||||
@@ -439,6 +441,10 @@ node snapshot.
|
||||
- Emitted as ordinary node records sharing one `nodeId`, each carrying its own
|
||||
`destination` mapping, so no separate ingest route is involved.
|
||||
|
||||
### GET /api/nodes placeholder flag (SPEC MR4)
|
||||
|
||||
`GET /api/nodes` and `GET /api/nodes/:id` emit `synthetic: true` on a name-derived MeshCore placeholder row - a channel sender not yet matched to a keyed contact - and omit the key on every other row (no `synthetic: false`). Placeholders come only from ingested chat senders (see the `POST /api/nodes` placeholder note), never from mentions, and the reconciliation merge folds them into the real node once its contact is stored and the name is unambiguous (SPEC MR2).
|
||||
|
||||
### GET /api/destinations response shape
|
||||
|
||||
One row per announced destination, newest `last_heard` first.
|
||||
|
||||
@@ -82,7 +82,6 @@ from ._constants import ( # noqa: E402 - keep grouped with sibling re-exports.
|
||||
_CHANNEL_PROBE_FALLBACK_MAX,
|
||||
_CONNECT_TIMEOUT_SECS,
|
||||
_DEFAULT_BAUDRATE,
|
||||
_MENTION_RE,
|
||||
_MESHCORE_ADV_TYPE_ROLE,
|
||||
_MESHCORE_ID_BITS,
|
||||
_MESHCORE_ID_MASK,
|
||||
@@ -122,11 +121,9 @@ from .identity import ( # noqa: E402
|
||||
from .interface import ClosedBeforeConnectedError, _MeshcoreInterface # noqa: E402
|
||||
from .messages import ( # noqa: E402
|
||||
_derive_message_id,
|
||||
_extract_mention_names,
|
||||
_normalize_hops,
|
||||
_normalize_path,
|
||||
_parse_sender_name,
|
||||
_synthetic_node_dict,
|
||||
)
|
||||
from .position import _store_meshcore_position # noqa: E402
|
||||
from .provider import MeshcoreProvider # noqa: E402
|
||||
@@ -146,7 +143,6 @@ __all__ = [
|
||||
"_DEFAULT_BAUDRATE",
|
||||
"_IGNORED_MESSAGE_LOCK",
|
||||
"_IGNORED_MESSAGE_LOG_PATH",
|
||||
"_MENTION_RE",
|
||||
"_MESHCORE_ADV_TYPE_ROLE",
|
||||
"_MESHCORE_ID_BITS",
|
||||
"_MESHCORE_ID_MASK",
|
||||
@@ -158,7 +154,6 @@ __all__ = [
|
||||
"_derive_synthetic_node_id",
|
||||
"_ensure_autoadd_eviction",
|
||||
"_ensure_channel_names",
|
||||
"_extract_mention_names",
|
||||
"_log_unhandled_loop_exception",
|
||||
"_make_connection",
|
||||
"_make_event_handlers",
|
||||
@@ -178,6 +173,5 @@ __all__ = [
|
||||
"_rx_advert_to_node_dict",
|
||||
"_self_info_to_node_dict",
|
||||
"_store_meshcore_position",
|
||||
"_synthetic_node_dict",
|
||||
"_to_json_safe",
|
||||
]
|
||||
@@ -20,8 +20,6 @@ import only what they need without picking up unrelated side-effects.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_CONNECT_TIMEOUT_SECS: float = 30.0
|
||||
"""Seconds to wait for :class:`MeshcoreProvider.connect` to return.
|
||||
|
||||
@@ -88,6 +86,3 @@ instead of ``0``; any other value is the masked 6-bit hop count (the
|
||||
``meshcore`` library strips the 2-bit ``path_hash_mode`` prefix before
|
||||
dispatching, so handlers only ever see ``0``–``63`` or this sentinel).
|
||||
"""
|
||||
|
||||
# Matches @[Name] mention patterns in MeshCore message bodies.
|
||||
_MENTION_RE = re.compile(r"@\[([^\]]+)\]")
|
||||
@@ -32,11 +32,9 @@ from .identity import _derive_synthetic_node_id, _meshcore_node_id
|
||||
from .interface import _MeshcoreInterface
|
||||
from .messages import (
|
||||
_derive_message_id,
|
||||
_extract_mention_names,
|
||||
_normalize_hops,
|
||||
_normalize_path,
|
||||
_parse_sender_name,
|
||||
_synthetic_node_dict,
|
||||
)
|
||||
from .position import _store_meshcore_position
|
||||
from .telemetry import _make_telemetry_handlers
|
||||
@@ -259,31 +257,17 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
|
||||
# MeshCore channel messages carry no sender identifier in the event
|
||||
# payload. Try to resolve the sender from the "SenderName: body"
|
||||
# convention embedded in the message text, matched against the known
|
||||
# contacts roster. When the contacts roster does not yet contain the
|
||||
# sender, create a synthetic placeholder node so that the message
|
||||
# receives a stable from_id and the UI can render a badge immediately.
|
||||
# The web app will migrate messages to the real node ID once the sender
|
||||
# is seen via a contact advertisement.
|
||||
# contacts roster. When the contacts roster does not contain the
|
||||
# sender, the message carries a name-derived synthetic from_id. No
|
||||
# node is POSTed for it: the web app mints the placeholder from the
|
||||
# message text once the message has passed the channel filters, and
|
||||
# migrates it to the real node ID when a contact advertisement arrives
|
||||
# (SPEC GN4). ``@[Name]`` mentions are not receptions and never become
|
||||
# nodes (SPEC GN1).
|
||||
sender_name = _parse_sender_name(text)
|
||||
from_id = iface.lookup_node_id_by_name(sender_name) if sender_name else None
|
||||
if from_id is None and sender_name:
|
||||
synthetic_id = _derive_synthetic_node_id(sender_name)
|
||||
if synthetic_id not in iface._synthetic_node_ids:
|
||||
_handlers.upsert_node(synthetic_id, _synthetic_node_dict(sender_name))
|
||||
iface._synthetic_node_ids.add(synthetic_id)
|
||||
from_id = synthetic_id
|
||||
|
||||
# Upsert synthetic placeholder nodes for any @[Name] mentions in the
|
||||
# message body whose names are not yet in the contacts roster. This
|
||||
# ensures mention badges resolve even before the mentioned node is seen.
|
||||
for mention_name in _extract_mention_names(text):
|
||||
if not iface.lookup_node_id_by_name(mention_name):
|
||||
mention_id = _derive_synthetic_node_id(mention_name)
|
||||
if mention_id not in iface._synthetic_node_ids:
|
||||
_handlers.upsert_node(
|
||||
mention_id, _synthetic_node_dict(mention_name)
|
||||
)
|
||||
iface._synthetic_node_ids.add(mention_id)
|
||||
from_id = _derive_synthetic_node_id(sender_name)
|
||||
|
||||
# The dedup fingerprint uses the parsed sender name (lowercased and
|
||||
# stripped) rather than ``from_id``: each ingestor independently
|
||||
|
||||
@@ -54,12 +54,6 @@ class _MeshcoreInterface:
|
||||
self._contacts_lock = threading.Lock()
|
||||
self._contacts: dict = {}
|
||||
self.isConnected: bool = False
|
||||
# Tracks synthetic node IDs already upserted this session to avoid
|
||||
# repeating the HTTP POST for every message from the same unknown sender.
|
||||
# This set is reset on reconnect (because _MeshcoreInterface is recreated),
|
||||
# which may cause extra upserts after a disconnect — the ON CONFLICT guard
|
||||
# in the Ruby web app ensures those are idempotent and safe.
|
||||
self._synthetic_node_ids: set[str] = set()
|
||||
self._self_info_payload: dict | None = None
|
||||
"""Most recent SELF_INFO payload received from the device, or ``None``."""
|
||||
|
||||
@@ -136,6 +130,11 @@ class _MeshcoreInterface:
|
||||
available in the event payload. The comparison is case-sensitive
|
||||
because ``adv_name`` values come verbatim from the MeshCore firmware.
|
||||
|
||||
A roster keeps a retired keypair under its old name until it is
|
||||
evicted, so several contacts can share one name. The contact with the
|
||||
newest ``last_advert`` wins; on a tie the first one in roster order
|
||||
does (SPEC GN3).
|
||||
|
||||
Parameters:
|
||||
adv_name: Advertised name to look up. Leading and trailing
|
||||
whitespace is stripped before comparison.
|
||||
@@ -147,12 +146,17 @@ class _MeshcoreInterface:
|
||||
name = adv_name.strip() if adv_name else ""
|
||||
if not name:
|
||||
return None
|
||||
best_key = None
|
||||
best_advert = -1
|
||||
with self._contacts_lock:
|
||||
for pub_key, contact in self._contacts.items():
|
||||
contact_name = (contact.get("adv_name") or "").strip()
|
||||
if contact_name == name:
|
||||
return _meshcore_node_id(pub_key)
|
||||
return None
|
||||
if contact_name != name:
|
||||
continue
|
||||
advert = contact.get("last_advert") or 0
|
||||
if advert > best_advert:
|
||||
best_key, best_advert = pub_key, advert
|
||||
return _meshcore_node_id(best_key) if best_key else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from ._constants import _DIRECT_PATH_LEN, _MENTION_RE, _MESHCORE_ID_MASK
|
||||
from ._constants import _DIRECT_PATH_LEN, _MESHCORE_ID_MASK
|
||||
|
||||
|
||||
def _normalize_hops(path_len: object) -> int | None:
|
||||
@@ -134,48 +133,3 @@ def _parse_sender_name(text: str) -> str | None:
|
||||
return None
|
||||
name = text[:colon_idx].strip()
|
||||
return name if name else None
|
||||
|
||||
|
||||
def _extract_mention_names(text: str) -> list[str]:
|
||||
"""Extract all ``@[Name]`` mention names from a MeshCore message body.
|
||||
|
||||
Parameters:
|
||||
text: Raw message text that may contain ``@[Name]`` mention patterns.
|
||||
|
||||
Returns:
|
||||
List of extracted name strings (may be empty).
|
||||
"""
|
||||
return _MENTION_RE.findall(text)
|
||||
|
||||
|
||||
def _synthetic_node_dict(long_name: str) -> dict:
|
||||
"""Build a synthetic node dict for an unknown MeshCore channel sender.
|
||||
|
||||
Synthetic nodes are placeholder entries created when a channel message
|
||||
arrives from a sender who is not yet in the connected device's contacts
|
||||
roster. They carry ``role=COMPANION`` (the only role capable of sending
|
||||
channel messages). The short name is intentionally omitted here — the
|
||||
Ruby web app derives it at query time via
|
||||
``meshcore_companion_display_short_name`` for all COMPANION nodes.
|
||||
|
||||
When the real contact advertisement is later received, the Ruby web app
|
||||
detects the matching long name, migrates all messages from the synthetic
|
||||
node ID to the real one, and removes the placeholder row.
|
||||
|
||||
Parameters:
|
||||
long_name: Sender name parsed from the ``"SenderName: body"`` prefix.
|
||||
|
||||
Returns:
|
||||
Node dict compatible with the ``POST /api/nodes`` payload format,
|
||||
with ``user.synthetic`` set to ``True``.
|
||||
"""
|
||||
return {
|
||||
"lastHeard": int(time.time()),
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": long_name,
|
||||
"shortName": "",
|
||||
"role": "COMPANION",
|
||||
"synthetic": True,
|
||||
},
|
||||
}
|
||||
+87
-116
@@ -66,7 +66,6 @@ from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup
|
||||
_derive_modem_preset,
|
||||
_derive_synthetic_node_id,
|
||||
_ensure_channel_names,
|
||||
_extract_mention_names,
|
||||
_make_connection,
|
||||
_make_event_handlers,
|
||||
_meshcore_adv_type_to_role,
|
||||
@@ -82,7 +81,6 @@ from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup
|
||||
_record_meshcore_message,
|
||||
_self_info_to_node_dict,
|
||||
_store_meshcore_position,
|
||||
_synthetic_node_dict,
|
||||
_to_json_safe,
|
||||
)
|
||||
|
||||
@@ -806,54 +804,6 @@ def test_derive_synthetic_node_id_unicode():
|
||||
assert len(nid) == 9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _synthetic_node_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_synthetic_node_dict_fields():
|
||||
"""_synthetic_node_dict returns a node dict with correct user fields."""
|
||||
nd = _synthetic_node_dict("T114-Zeh")
|
||||
assert nd["protocol"] == "meshcore"
|
||||
assert nd["user"]["longName"] == "T114-Zeh"
|
||||
assert nd["user"]["role"] == "COMPANION"
|
||||
assert nd["user"]["synthetic"] is True
|
||||
assert isinstance(nd["lastHeard"], int)
|
||||
|
||||
|
||||
def test_synthetic_node_dict_short_name_empty():
|
||||
"""Short name is always empty — the Ruby web app derives it at query time."""
|
||||
nd = _synthetic_node_dict("pete 🍁")
|
||||
assert nd["user"]["shortName"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_mention_names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_mention_names_single():
|
||||
"""Extracts one mention name."""
|
||||
assert _extract_mention_names("Hey @[Alice]!") == ["Alice"]
|
||||
|
||||
|
||||
def test_extract_mention_names_multiple():
|
||||
"""Extracts multiple mention names in order."""
|
||||
assert _extract_mention_names("@[Alpha] and @[Beta]") == ["Alpha", "Beta"]
|
||||
|
||||
|
||||
def test_extract_mention_names_none():
|
||||
"""Returns empty list when no mentions are present."""
|
||||
assert _extract_mention_names("no mentions here") == []
|
||||
|
||||
|
||||
def test_extract_mention_names_preserves_spaces():
|
||||
"""Names with spaces inside brackets are preserved."""
|
||||
assert _extract_mention_names("Hi @[MaLiBu'2 Britz-Sued]") == [
|
||||
"MaLiBu'2 Britz-Sued"
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _MeshcoreInterface.lookup_node_id_by_name
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -912,6 +862,38 @@ def test_lookup_node_id_by_name_multiple_contacts():
|
||||
assert iface.lookup_node_id_by_name("Beta") == "!22222222"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retired_first", [True, False])
|
||||
def test_lookup_node_id_by_name_prefers_newest_last_advert(retired_first):
|
||||
"""A name held by a retired and a live keypair resolves to the newest advert.
|
||||
|
||||
A roster keeps a retired keypair under its old name until it is evicted, so
|
||||
insertion order alone would hand the retired id to every channel message
|
||||
from that name (SPEC GN3).
|
||||
"""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
retired = {
|
||||
"public_key": "dddd0001" + "00" * 28,
|
||||
"adv_name": "Carol",
|
||||
"last_advert": 1_750_000_000,
|
||||
}
|
||||
live = {
|
||||
"public_key": "cccc0001" + "00" * 28,
|
||||
"adv_name": "Carol",
|
||||
"last_advert": 1_759_000_000,
|
||||
}
|
||||
for contact in (retired, live) if retired_first else (live, retired):
|
||||
iface._update_contact(contact)
|
||||
assert iface.lookup_node_id_by_name("Carol") == "!cccc0001"
|
||||
|
||||
|
||||
def test_lookup_node_id_by_name_keeps_first_contact_on_equal_last_advert():
|
||||
"""Without a newer advert to prefer, the first same-name contact wins."""
|
||||
iface = _MeshcoreInterface(target=None)
|
||||
iface._update_contact({"public_key": "11111111" + "00" * 28, "adv_name": "Twin"})
|
||||
iface._update_contact({"public_key": "22222222" + "00" * 28, "adv_name": "Twin"})
|
||||
assert iface.lookup_node_id_by_name("Twin") == "!11111111"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _meshcore_adv_type_to_role
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2379,59 +2361,33 @@ def test_on_channel_msg_resolves_from_id_via_sender_name(monkeypatch):
|
||||
assert pkt["from_id"] == "!aabbccdd"
|
||||
|
||||
|
||||
def test_on_channel_msg_creates_synthetic_node_when_sender_not_in_contacts(monkeypatch):
|
||||
"""on_channel_msg upserts a synthetic node and sets from_id when sender is unknown."""
|
||||
def test_on_channel_msg_derives_synthetic_from_id_without_posting_a_node(monkeypatch):
|
||||
"""An unrostered sender gets the name-derived from_id but no node POST.
|
||||
|
||||
The web app mints the placeholder from the message text once the message
|
||||
has passed the channel filters (SPEC GN4), so the ingestor queues no
|
||||
``/api/nodes`` upsert for it, however many messages the sender posts.
|
||||
"""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_003,
|
||||
"text": "UnknownSender: Hello",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
for sender_ts, text in ((1_758_000_003, "First"), (1_758_000_004, "Second")):
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": sender_ts,
|
||||
"text": f"UnknownSender: {text}",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
expected_id = _derive_synthetic_node_id("UnknownSender")
|
||||
assert captured[0]["from_id"] == expected_id
|
||||
# A synthetic node should have been upserted for the sender.
|
||||
synth_upserts = [(nid, nd) for nid, nd in upserted if nid == expected_id]
|
||||
assert len(synth_upserts) == 1
|
||||
synth_node = synth_upserts[0][1]
|
||||
assert synth_node["user"]["longName"] == "UnknownSender"
|
||||
assert synth_node["user"]["role"] == "COMPANION"
|
||||
assert synth_node["user"]["synthetic"] is True
|
||||
|
||||
|
||||
def test_on_channel_msg_synthetic_upserted_only_once_per_session(monkeypatch):
|
||||
"""on_channel_msg only calls upsert_node once per unique synthetic ID per session."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
payload = {
|
||||
"sender_timestamp": 1_758_000_010,
|
||||
"text": "UnknownSender: First",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt(payload)))
|
||||
payload2 = {
|
||||
"sender_timestamp": 1_758_000_011,
|
||||
"text": "UnknownSender: Second",
|
||||
"channel_idx": 0,
|
||||
}
|
||||
asyncio.run(hmap["CHANNEL_MSG_RECV"](_FakeEvt(payload2)))
|
||||
|
||||
expected_id = _derive_synthetic_node_id("UnknownSender")
|
||||
sender_upserts = [nid for nid, _ in upserted if nid == expected_id]
|
||||
# Second message must NOT re-upsert the same synthetic node.
|
||||
assert len(sender_upserts) == 1
|
||||
assert [pkt["from_id"] for pkt in captured] == [expected_id, expected_id]
|
||||
assert upserted == []
|
||||
|
||||
|
||||
def test_on_channel_msg_no_synthetic_when_no_sender_prefix(monkeypatch):
|
||||
@@ -2456,12 +2412,15 @@ def test_on_channel_msg_no_synthetic_when_no_sender_prefix(monkeypatch):
|
||||
assert upserted == []
|
||||
|
||||
|
||||
def test_on_channel_msg_upserts_synthetic_for_unknown_mention(monkeypatch):
|
||||
"""on_channel_msg upserts synthetic nodes for @[Name] mentions not in contacts."""
|
||||
def test_on_channel_msg_posts_no_node_for_mentions(monkeypatch):
|
||||
"""A mention is not a reception: no node POST, rostered or not (SPEC GN1)."""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch)
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(
|
||||
monkeypatch,
|
||||
contacts=[{"public_key": pub_key, "adv_name": "Bob"}],
|
||||
)
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
@@ -2474,38 +2433,50 @@ def test_on_channel_msg_upserts_synthetic_for_unknown_mention(monkeypatch):
|
||||
)
|
||||
)
|
||||
|
||||
upserted_ids = {nid for nid, _ in upserted}
|
||||
assert _derive_synthetic_node_id("Bob") in upserted_ids
|
||||
assert _derive_synthetic_node_id("Carol") in upserted_ids
|
||||
assert len(captured) == 1
|
||||
assert upserted == []
|
||||
|
||||
|
||||
def test_on_channel_msg_skips_synthetic_for_known_mention(monkeypatch):
|
||||
"""on_channel_msg does not upsert synthetic for @[Name] if name is in contacts."""
|
||||
def test_on_channel_msg_hidden_channel_queues_nothing(monkeypatch):
|
||||
"""A ``HIDDEN_CHANNELS`` message leaks neither its text nor its names.
|
||||
|
||||
Runs the real ``handlers`` module with only the HTTP queue captured, so the
|
||||
channel filter in ``store_packet_dict`` applies exactly as in production;
|
||||
names parsed from the hidden text must not reach ``/api/nodes`` either.
|
||||
"""
|
||||
import asyncio
|
||||
from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id
|
||||
import data.mesh_ingestor.channels as _channels
|
||||
import data.mesh_ingestor.protocols.meshcore as _mod
|
||||
import data.mesh_ingestor.queue as _queue_mod
|
||||
|
||||
pub_key = "aabbccdd" + "00" * 28
|
||||
captured, upserted, _iface, hmap = _setup_channel_msg_handlers(
|
||||
monkeypatch,
|
||||
contacts=[{"public_key": pub_key, "adv_name": "Bob"}],
|
||||
sent: list = []
|
||||
monkeypatch.setattr(
|
||||
_queue_mod,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority, **_kw: sent.append(path),
|
||||
)
|
||||
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
|
||||
# DEBUG off: the dropped packet must not be appended to the ignored log.
|
||||
monkeypatch.setattr(_mod.config, "DEBUG", False)
|
||||
monkeypatch.setattr(_mod.config, "HIDDEN_CHANNELS", ("Secret",))
|
||||
monkeypatch.setattr(_mod.config, "ALLOWED_CHANNELS", ())
|
||||
monkeypatch.setattr(_mod.config, "PRIMARY_CHANNEL_ONLY", False)
|
||||
monkeypatch.setattr(_channels, "_CHANNEL_LOOKUP", {5: "Secret"})
|
||||
|
||||
hmap = _make_event_handlers(_MeshcoreInterface(target=None), "/dev/ttyUSB0")
|
||||
asyncio.run(
|
||||
hmap["CHANNEL_MSG_RECV"](
|
||||
_FakeEvt(
|
||||
{
|
||||
"sender_timestamp": 1_758_000_006,
|
||||
"text": "Alice: Hey @[Bob]",
|
||||
"channel_idx": 0,
|
||||
"text": "Erin: meet @[Ghosty] at 8",
|
||||
"channel_idx": 5,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Bob is in contacts — no synthetic upsert for Bob.
|
||||
bob_upserts = [
|
||||
nid for nid, _ in upserted if nid == _derive_synthetic_node_id("Bob")
|
||||
]
|
||||
assert bob_upserts == []
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_on_contact_msg_queues_packet_with_from_id(monkeypatch):
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "digest"
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module DataProcessing
|
||||
@@ -23,18 +21,18 @@ module PotatoMesh
|
||||
# channel chat text (issue #803).
|
||||
#
|
||||
# A MeshCore channel message encodes its sender as a +"SenderName: body"+
|
||||
# text prefix and quotes/mentions peers as +@[Name]+. The sender's
|
||||
# +from_id+ is a name-derived synthetic id. These helpers create the
|
||||
# placeholder named from that text and marked +synthetic+, so the existing
|
||||
# issue-#755 merge machinery reconciles it with the real contact
|
||||
# advertisement — instead of the generic +"MeshCore <hex>"+ stand-in that
|
||||
# +ensure_unknown_node+ would mint (which is mis-recorded as a real
|
||||
# +synthetic=0+ node, shows the wrong name, and never reconciles).
|
||||
|
||||
# Matches +@[Name]+ mention patterns in a MeshCore message body. Mirrors
|
||||
# the ingestor's +_MENTION_RE+ and the frontend mention regex so all three
|
||||
# layers agree on what a mention is.
|
||||
MESHCORE_MENTION_RE = /@\[([^\]]+)\]/.freeze
|
||||
# text prefix. An unrostered sender's +from_id+ is a name-derived
|
||||
# synthetic id. These helpers create the placeholder named from that text
|
||||
# and marked +synthetic+, so the existing issue-#755 merge machinery
|
||||
# reconciles it with the real contact advertisement — instead of the
|
||||
# generic +"MeshCore <hex>"+ stand-in that +ensure_unknown_node+ would mint
|
||||
# (which is mis-recorded as a real +synthetic=0+ node, shows the wrong
|
||||
# name, and never reconciles).
|
||||
#
|
||||
# Only the sender is a reception. An +@[Name]+ mention or reply prefix
|
||||
# names a peer that was not heard, so it never creates, refreshes, or
|
||||
# merges a node (SPEC GN1, issue #883); the frontend renders unresolved
|
||||
# mentions with its own stand-in badge.
|
||||
|
||||
# Parse the sender long name from a MeshCore +"SenderName: body"+ prefix.
|
||||
#
|
||||
@@ -54,36 +52,6 @@ module PotatoMesh
|
||||
name.empty? ? nil : name
|
||||
end
|
||||
|
||||
# Extract the trimmed, de-duplicated +@[Name]+ mentions from a body.
|
||||
#
|
||||
# @param text [String, nil] raw message text.
|
||||
# @return [Array<String>] mention names in first-seen order (may be empty).
|
||||
def extract_meshcore_mentions(text)
|
||||
return [] unless text.is_a?(String)
|
||||
|
||||
text.scan(MESHCORE_MENTION_RE).map { |match| match[0].strip }.reject(&:empty?).uniq
|
||||
end
|
||||
|
||||
# Derive the deterministic synthetic node id for a MeshCore display name.
|
||||
#
|
||||
# Uses the first four bytes of +SHA-256(UTF-8 name)+ as +"!xxxxxxxx"+. The
|
||||
# name is trimmed first so a reference and its bracket-padded variant
|
||||
# (+@[ Name ]+) converge on one row and align with the frontend's trimmed
|
||||
# mention/sender resolution; for the unpadded names that dominate in
|
||||
# practice this equals the Python ingestor's +_derive_synthetic_node_id+
|
||||
# (the sender path does not derive at all — it reuses the message +from_id+).
|
||||
#
|
||||
# @param name [String, nil] display name.
|
||||
# @return [String, nil] canonical +"!xxxxxxxx"+ id, or nil for a blank name.
|
||||
def meshcore_synthetic_node_id(name)
|
||||
return nil unless name.is_a?(String)
|
||||
|
||||
trimmed = name.strip
|
||||
return nil if trimmed.empty?
|
||||
|
||||
"!" + Digest::SHA256.hexdigest(trimmed)[0, 8]
|
||||
end
|
||||
|
||||
# Rank a candidate MeshCore sender id by the strength of its identity
|
||||
# evidence (SPEC MR3).
|
||||
#
|
||||
@@ -130,6 +98,40 @@ module PotatoMesh
|
||||
meshcore_sender_rank(db, incoming_from_id) > meshcore_sender_rank(db, existing_from_id)
|
||||
end
|
||||
|
||||
# Resolve the sender of a MeshCore channel message that the ingestor
|
||||
# name-matched to a retired identity (SPEC GN3, extends MR3).
|
||||
#
|
||||
# A channel message carries no sender key: the ingestor maps the
|
||||
# +"Name:"+ prefix onto its own contact roster, and a roster that still
|
||||
# holds a retired keypair under that name hands back the retired id. MR3
|
||||
# only ranks *competing* copies, so a sole copy would keep the retired
|
||||
# node alive. The same ranks decide here: a positively-stale sender
|
||||
# (rank 1) is replaced by the one same-name real node that is live or
|
||||
# unproven (rank 2). No such node, or more than one, keeps the
|
||||
# ingestor's id.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param from_id [String, nil] sender id carried by the message.
|
||||
# @param to_id [String, nil] resolved recipient (+"^all"+ for channel chat).
|
||||
# @param text [String, nil] raw message text.
|
||||
# @return [String, nil] the sender id to store, +from_id+ when unchanged.
|
||||
def resolve_meshcore_channel_sender(db, from_id, to_id, text)
|
||||
return from_id unless to_id.to_s == "^all"
|
||||
|
||||
sender_name = parse_meshcore_sender_name(text)
|
||||
return from_id unless sender_name && meshcore_sender_rank(db, from_id) == 1
|
||||
|
||||
# NULL unless exactly one candidate qualifies, so ambiguity keeps the
|
||||
# ingestor's attribution rather than guessing between two live nodes.
|
||||
live_id = db.get_first_value(
|
||||
"SELECT CASE WHEN COUNT(*) = 1 THEN MAX(node_id) END FROM nodes " \
|
||||
"WHERE long_name = ? AND synthetic = 0 AND protocol = 'meshcore' AND node_id != ? " \
|
||||
"AND NOT #{DataProcessing.positively_stale_sql("nodes")}",
|
||||
[sender_name, from_id, DataProcessing.evidence_cutoff],
|
||||
)
|
||||
live_id || from_id
|
||||
end
|
||||
|
||||
# Create or repair the MeshCore chat placeholder node for a display name.
|
||||
#
|
||||
# The node is upserted as a synthetic (+synthetic=1+) COMPANION named
|
||||
@@ -139,7 +141,8 @@ module PotatoMesh
|
||||
# (+synthetic=0+) is first demoted to synthetic so the parsed name can take
|
||||
# over — the real-node guard in +upsert_node+ would otherwise protect the
|
||||
# stale generic name. A genuine real node (non-generic name, +synthetic=0+)
|
||||
# is left untouched.
|
||||
# is left untouched: it is the key-resolved sender itself, not a
|
||||
# placeholder (SPEC GN2).
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param node_id [String, nil] canonical node id to name.
|
||||
@@ -175,6 +178,10 @@ module PotatoMesh
|
||||
end
|
||||
return
|
||||
end
|
||||
# A genuine real node: nothing to name, and the synthetic upsert below
|
||||
# would hand the reverse merge a real id, moving this sender's
|
||||
# messages and last_heard onto a same-name sibling (issue #883).
|
||||
return
|
||||
end
|
||||
|
||||
upsert_node(
|
||||
@@ -194,14 +201,13 @@ module PotatoMesh
|
||||
)
|
||||
end
|
||||
|
||||
# Synthesize/repair sender + mention placeholder nodes for a MeshCore
|
||||
# channel message, replacing the generic +ensure_unknown_node+ placeholder
|
||||
# for the sender.
|
||||
# Synthesize/repair the sender placeholder node for a MeshCore channel
|
||||
# message, replacing the generic +ensure_unknown_node+ placeholder.
|
||||
#
|
||||
# Only broadcast (+"^all"+) MeshCore messages are channel chat; direct
|
||||
# messages carry no +"Name:"+ prefix, so a stray colon in their body must
|
||||
# not be mistaken for a sender. Mentions are synthesized for every channel
|
||||
# message (they exist only in channel chat).
|
||||
# not be mistaken for a sender. +@[Name]+ mentions are deliberately not
|
||||
# synthesized: a mention is not a reception (SPEC GN1).
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param from_id [String, nil] sender node id from the message.
|
||||
@@ -214,10 +220,6 @@ module PotatoMesh
|
||||
return false unless to_id.to_s == "^all"
|
||||
return false unless string_or_nil(text)
|
||||
|
||||
extract_meshcore_mentions(text).each do |mention|
|
||||
ensure_meshcore_chat_node(db, meshcore_synthetic_node_id(mention), mention, heard_time)
|
||||
end
|
||||
|
||||
sender = parse_meshcore_sender_name(text)
|
||||
return false unless sender && string_or_nil(from_id)
|
||||
|
||||
|
||||
@@ -185,6 +185,10 @@ module PotatoMesh
|
||||
# hop-hash route; both additive and absent for legacy senders.
|
||||
hops = coerce_integer(message["hops"])
|
||||
path = string_or_nil(message["path"])
|
||||
# A MeshCore channel sender is only name-matched by the ingestor, so a
|
||||
# stale roster can hand back a retired key; re-rank it before anything
|
||||
# is stored, deduplicated, or touched (SPEC GN3).
|
||||
from_id = resolve_meshcore_channel_sender(db, from_id, to_id, text) if protocol == "meshcore"
|
||||
|
||||
row = [
|
||||
msg_id,
|
||||
@@ -514,12 +518,13 @@ module PotatoMesh
|
||||
|
||||
should_touch_message = !stored_decrypted
|
||||
if should_touch_message
|
||||
# MeshCore channel messages name their sender (and quote/mention peers)
|
||||
# in the text; synthesize/repair those placeholder nodes (issue #803)
|
||||
# named from the text and flagged synthetic, so they reconcile with the
|
||||
# real contact — instead of the generic "MeshCore <hex>" placeholder
|
||||
# ensure_unknown_node would mint. Falls through to ensure_unknown_node
|
||||
# for non-MeshCore messages or when no sender prefix is present.
|
||||
# MeshCore channel messages name their sender in the text; synthesize/
|
||||
# repair that placeholder node (issue #803) named from the text and
|
||||
# flagged synthetic, so it reconciles with the real contact — instead
|
||||
# of the generic "MeshCore <hex>" placeholder ensure_unknown_node would
|
||||
# mint. Mentioned peers were not heard and get no node (SPEC GN1).
|
||||
# Falls through to ensure_unknown_node for non-MeshCore messages or
|
||||
# when no sender prefix is present.
|
||||
meshcore_sender_named =
|
||||
protocol == "meshcore" &&
|
||||
process_meshcore_chat_nodes(db, resolved_from_id || raw_from_id, to_id || raw_to_id, text, rx_time)
|
||||
|
||||
@@ -713,11 +713,21 @@ module PotatoMesh
|
||||
# pubkey-derived real node coexisted because the forward merge only fired
|
||||
# on real-node upserts and never back-filled late-arriving synthetics.
|
||||
#
|
||||
# Only a stored placeholder is folded (SPEC GN2). A synthetic-flagged
|
||||
# record for an id whose row is real is blocked by the +upsert_node+
|
||||
# guard, and merging it would move that real node's messages and
|
||||
# +last_heard+ onto a same-name sibling (issue #883), so it is a no-op.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database connection.
|
||||
# @param synthetic_node_id [String] canonical node ID of the synthetic placeholder being upserted.
|
||||
# @param long_name [String] long name to match against existing real rows.
|
||||
# @return [void]
|
||||
def merge_into_real_node(db, synthetic_node_id, long_name)
|
||||
return unless db.get_first_value(
|
||||
"SELECT 1 FROM nodes WHERE node_id = ? AND synthetic = 1 LIMIT 1",
|
||||
[synthetic_node_id],
|
||||
)
|
||||
|
||||
# Read the single node_id column shape-robustly (see the +row.is_a?(Hash)+
|
||||
# guard below): a +results_as_hash = true+ handle yields plain Hash rows
|
||||
# with string keys only under sqlite3 2.x, where integer indexing (+row[0]+)
|
||||
|
||||
@@ -48,6 +48,10 @@ module PotatoMesh
|
||||
data.each do |node_id, node|
|
||||
next if node_id == "ingestor"
|
||||
next if node_id == "protocol"
|
||||
# Name-derived placeholders are minted web-side from the message
|
||||
# text that names them (SPEC GN4); a POSTed one records no
|
||||
# reception, and older ingestors also posted them for mentions.
|
||||
next if node.is_a?(Hash) && node["user"].is_a?(Hash) && node["user"]["synthetic"]
|
||||
per_node = node.is_a?(Hash) ? normalize_protocol_value(node["protocol"]) : nil
|
||||
upsert_node(db, node_id, node, protocol: per_node || batch_protocol)
|
||||
end
|
||||
|
||||
@@ -3604,22 +3604,6 @@ RSpec.describe PotatoMesh::App::DataProcessing do
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "synthesizes a placeholder for a mention-only name that never sent a message" do
|
||||
db = open_db
|
||||
# derive("Silent Sweeper") == !8dbb4718 — mentioned, never a sender.
|
||||
dp.insert_message(db, meshcore_channel_message(
|
||||
"id" => 4243,
|
||||
"from_id" => "!ebc4edf0",
|
||||
"text" => "RS 26: @[Silent Sweeper] dann Grüße aus Hundshübel",
|
||||
))
|
||||
row = node_for(db, "!8dbb4718")
|
||||
expect(row).not_to be_nil
|
||||
expect(row["long_name"]).to eq("Silent Sweeper")
|
||||
expect(row["synthetic"]).to eq(1)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "falls back to the generic placeholder for a meshcore direct message (not channel chat)" do
|
||||
db = open_db
|
||||
# to_id is a host node, not "^all": a stray colon in the DM body must not
|
||||
@@ -3676,7 +3660,7 @@ RSpec.describe PotatoMesh::App::DataProcessing do
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MeshCore chat text parsing & id derivation (issue #803).
|
||||
# MeshCore chat text parsing (issue #803).
|
||||
# ---------------------------------------------------------------------------
|
||||
describe "meshcore chat text parsing" do
|
||||
it "parses the sender name before the first colon" do
|
||||
@@ -3691,22 +3675,5 @@ RSpec.describe PotatoMesh::App::DataProcessing do
|
||||
expect(dp.parse_meshcore_sender_name(nil)).to be_nil
|
||||
expect(dp.parse_meshcore_sender_name(42)).to be_nil
|
||||
end
|
||||
|
||||
it "extracts trimmed, de-duplicated @[Name] mentions in first-seen order" do
|
||||
expect(
|
||||
dp.extract_meshcore_mentions("RS 26: @[Silent Sweeper] hi @[ Lipoly ] @[Silent Sweeper]"),
|
||||
).to eq(["Silent Sweeper", "Lipoly"])
|
||||
expect(dp.extract_meshcore_mentions("no mentions")).to eq([])
|
||||
expect(dp.extract_meshcore_mentions(nil)).to eq([])
|
||||
end
|
||||
|
||||
it "derives a deterministic id matching the ingestor and frontend" do
|
||||
expect(dp.meshcore_synthetic_node_id("DWeb 0229")).to eq("!0f6de6b3")
|
||||
expect(dp.meshcore_synthetic_node_id("Silent Sweeper")).to eq("!8dbb4718")
|
||||
# Trimmed before hashing so padded references converge on one row.
|
||||
expect(dp.meshcore_synthetic_node_id(" DWeb 0229 ")).to eq("!0f6de6b3")
|
||||
expect(dp.meshcore_synthetic_node_id(" ")).to be_nil
|
||||
expect(dp.meshcore_synthetic_node_id(nil)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "spec_helper"
|
||||
require "sqlite3"
|
||||
require "json"
|
||||
require "digest"
|
||||
|
||||
# MeshCore ghost nodes from chat text (issue #883; SPEC GN1-GN4, ACCEPTANCE
|
||||
# GN-A1 - GN-A5). A MeshCore channel message names its sender only in a
|
||||
# "Name: body" prefix and may mention peers as @[Name]; neither is proof that a
|
||||
# particular key was heard. These examples pin that chat text never mints,
|
||||
# refreshes, or revives a node it did not hear: a mention is not a reception
|
||||
# (GN1), a key-resolved sender never merges into a same-name sibling (GN2), a
|
||||
# sole copy name-resolved to a retired key credits the live key (GN3), and a
|
||||
# POSTed name-derived placeholder records nothing (GN4).
|
||||
RSpec.describe "MeshCore ghost nodes from chat text (issue #883)" do
|
||||
# Minimal host mixing in the data-processing pipeline, stubbed like the
|
||||
# harness in data_processing_spec.rb.
|
||||
let(:harness_class) do
|
||||
Class.new do
|
||||
include PotatoMesh::App::DataProcessing
|
||||
include PotatoMesh::App::Helpers
|
||||
|
||||
def debug_log(message, **); end
|
||||
|
||||
def warn_log(message, **); end
|
||||
|
||||
def with_busy_retry
|
||||
yield
|
||||
end
|
||||
|
||||
def update_prometheus_metrics(*); end
|
||||
|
||||
def prom_report_ids
|
||||
[]
|
||||
end
|
||||
|
||||
def private_mode?
|
||||
false
|
||||
end
|
||||
|
||||
def normalize_node_id(_db, node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
parts ? parts[0] : nil
|
||||
end
|
||||
|
||||
def resolve_protocol(_db, _ingestor, cache: nil)
|
||||
"meshtastic"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subject(:dp) { harness_class.new }
|
||||
|
||||
let(:now) { Time.now.to_i }
|
||||
let(:day) { 86_400 }
|
||||
# An unrostered sender: the ingestor posts her name-derived id.
|
||||
let(:erin) { derived_id("Erin") }
|
||||
|
||||
# Fresh database per example. The production windows are kept (7-day node
|
||||
# list, 28-day keyed-evidence horizon), so "positively stale" means what it
|
||||
# means in the field. The Rack routes open the same stubbed path.
|
||||
around do |example|
|
||||
Dir.mktmpdir("ghost-nodes-spec-") do |dir|
|
||||
RSpec::Mocks.with_temporary_scope do
|
||||
allow(PotatoMesh::Config).to receive(:db_path).and_return(File.join(dir, "mesh.db"))
|
||||
allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000)
|
||||
allow(PotatoMesh::Config).to receive(:debug?).and_return(false)
|
||||
db_helper = Object.new.extend(PotatoMesh::App::Database)
|
||||
db_helper.init_db
|
||||
db_helper.ensure_schema_upgrades
|
||||
example.run
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Open a hash-row handle on the example database.
|
||||
#
|
||||
# @return [SQLite3::Database] open database handle.
|
||||
def open_db
|
||||
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
||||
db.results_as_hash = true
|
||||
db
|
||||
end
|
||||
|
||||
# Name-derived placeholder id, as the ingestor's +_derive_synthetic_node_id+
|
||||
# computes it for an unrostered sender.
|
||||
#
|
||||
# @param name [String] display name.
|
||||
# @return [String] canonical +!xxxxxxxx+ id.
|
||||
def derived_id(name)
|
||||
"!" + Digest::SHA256.hexdigest(name)[0, 8]
|
||||
end
|
||||
|
||||
# Store a keyed MeshCore contact (roster or advert record) last heard at
|
||||
# +heard+; its keyed evidence (+last_advert_heard+) is +heard+ as well.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param node_id [String] canonical node id.
|
||||
# @param name [String] advertised long name.
|
||||
# @param key_byte [String] two hex digits repeated into the public key.
|
||||
# @param heard [Integer] unix seconds of the record.
|
||||
# @return [void]
|
||||
def seed_keyed_node(db, node_id, name, key_byte, heard)
|
||||
dp.upsert_node(db, node_id, {
|
||||
"lastHeard" => heard,
|
||||
"protocol" => "meshcore",
|
||||
"user" => { "longName" => name, "shortName" => key_byte, "role" => "COMPANION", "publicKey" => key_byte * 32 },
|
||||
}, protocol: "meshcore")
|
||||
end
|
||||
|
||||
# Store a MeshCore row with no keyed evidence and no position: a legacy real
|
||||
# row (MR2 "unknown") or, with +synthetic: 1+, a chat placeholder.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param node_id [String] canonical node id.
|
||||
# @param name [String] long name.
|
||||
# @param heard [Integer] unix seconds stored as last and first heard.
|
||||
# @param synthetic [Integer] 1 for a placeholder row, 0 for a real one.
|
||||
# @return [void]
|
||||
def seed_unkeyed_node(db, node_id, name, heard, synthetic: 0)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id,long_name,role,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?,?)",
|
||||
[node_id, name, "COMPANION", "meshcore", synthetic, heard, heard],
|
||||
)
|
||||
end
|
||||
|
||||
# Store a message row directly, bypassing ingest, attributed to +from_id+.
|
||||
#
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param id [Integer] message id.
|
||||
# @param from_id [String] sender node id.
|
||||
# @param text [String] message text.
|
||||
# @param rx_time [Integer] unix seconds of reception.
|
||||
# @return [void]
|
||||
def seed_message(db, id, from_id, text, rx_time)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,text,protocol) VALUES (?,?,?,?,?,?,?,?)",
|
||||
[id, rx_time, Time.at(rx_time).utc.iso8601, from_id, "^all", 0, text, "meshcore"],
|
||||
)
|
||||
end
|
||||
|
||||
# MeshCore channel message as the ingestor posts it.
|
||||
#
|
||||
# @param id [Integer] message id.
|
||||
# @param from_id [String] sender id the ingestor resolved.
|
||||
# @param text [String] "Name: body" text.
|
||||
# @param rx_time [Integer] unix seconds of reception.
|
||||
# @return [Hash] +POST /api/messages+ payload.
|
||||
def channel_message(id, from_id, text, rx_time)
|
||||
{
|
||||
"id" => id, "rx_time" => rx_time, "from_id" => from_id, "to_id" => "^all",
|
||||
"channel" => 0, "channel_name" => "Public", "text" => text,
|
||||
"portnum" => "TEXT_MESSAGE_APP", "protocol" => "meshcore", "ingestor" => "!634069bc",
|
||||
}
|
||||
end
|
||||
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @param node_id [String] canonical node id.
|
||||
# @return [Integer, nil] the row's +last_heard+, nil when there is no row.
|
||||
def last_heard(db, node_id)
|
||||
db.get_first_value("SELECT last_heard FROM nodes WHERE node_id = ?", [node_id])
|
||||
end
|
||||
|
||||
# @param db [SQLite3::Database] open database handle.
|
||||
# @return [Array<Array(Integer, String)>] +[id, from_id]+ per message, by id.
|
||||
def attributions(db)
|
||||
db.execute("SELECT id, from_id FROM messages ORDER BY id").map { |row| [row["id"], row["from_id"]] }
|
||||
end
|
||||
|
||||
describe "mentions (GN1)" do
|
||||
it "does not advance a mentioned real node's last_heard" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, "!dddd0002", "Dave", "d2", now - 3 * day)
|
||||
dp.insert_message(db, channel_message(10, erin, "Erin: ping @[Dave]", now))
|
||||
expect(last_heard(db, "!dddd0002")).to eq(now - 3 * day)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "does not pull a node last heard 20 days ago back into the 7-day window" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, "!dddd0003", "Zed", "d3", now - 20 * day)
|
||||
dp.insert_message(db, channel_message(11, erin, "Erin: @[Zed] still around?", now))
|
||||
expect(last_heard(db, "!dddd0003")).to eq(now - 20 * day)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "creates no row for a name that is only mentioned, and still names the sender" do
|
||||
db = open_db
|
||||
dp.insert_message(db, channel_message(12, erin, "Erin: hi @[Ghosty] and @[ Ghosty ]", now))
|
||||
aggregate_failures do
|
||||
expect(last_heard(db, derived_id("Ghosty"))).to be_nil
|
||||
expect(db.get_first_value("SELECT COUNT(*) FROM nodes WHERE long_name = 'Ghosty'")).to eq(0)
|
||||
# The sender was heard: her placeholder is named from the prefix.
|
||||
row = db.execute("SELECT long_name, synthetic, last_heard FROM nodes WHERE node_id = ?", [erin]).first
|
||||
expect(row.values_at("long_name", "synthetic", "last_heard")).to eq(["Erin", 1, now])
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "does not refresh an aged-out placeholder" do
|
||||
db = open_db
|
||||
seed_unkeyed_node(db, derived_id("Ghosty"), "Ghosty", now - 10 * day, synthetic: 1)
|
||||
dp.insert_message(db, channel_message(13, erin, "Erin: @[Ghosty] thanks", now))
|
||||
expect(last_heard(db, derived_id("Ghosty"))).to eq(now - 10 * day)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "does not hand a mention time to a later roster contact" do
|
||||
db = open_db
|
||||
dp.insert_message(db, channel_message(14, erin, "Erin: anyone seen @[Dave]?", now))
|
||||
# Dave's roster contact arrives afterwards, last advertised 3 days ago.
|
||||
seed_keyed_node(db, "!dddd0002", "Dave", "d2", now - 3 * day)
|
||||
expect(last_heard(db, "!dddd0002")).to eq(now - 3 * day)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
|
||||
describe "same-name real nodes (GN2)" do
|
||||
let(:name) { "Carol" }
|
||||
let(:sender) { "!cccc0001" }
|
||||
let(:retired) { "!dddd0001" }
|
||||
|
||||
it "leaves a genuine real sender's messages on its own id when a same-name real exists" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, sender, name, "cc", now - 120)
|
||||
seed_keyed_node(db, "!eeee0001", name, "ee", now - 600) # fresh same-name real
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day) # positively stale
|
||||
dp.insert_message(db, channel_message(20, sender, "#{name}: hello", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[20, sender]])
|
||||
expect(last_heard(db, sender)).to eq(now)
|
||||
expect(last_heard(db, "!eeee0001")).to eq(now - 600)
|
||||
expect(last_heard(db, retired)).to eq(now - 60 * day)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps the sender's messages and freezes a positively stale same-name node" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
seed_keyed_node(db, sender, name, "cc", now - 120)
|
||||
dp.insert_message(db, channel_message(21, sender, "#{name}: earlier", now - 3600))
|
||||
dp.insert_message(db, channel_message(22, sender, "#{name}: hello", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[21, sender], [22, sender]])
|
||||
expect(last_heard(db, retired)).to eq(now - 60 * day)
|
||||
expect(last_heard(db, sender)).to eq(now)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps the sender's messages beside a same-name node with no keyed evidence" do
|
||||
db = open_db
|
||||
seed_unkeyed_node(db, retired, name, now - 40 * day)
|
||||
seed_keyed_node(db, sender, name, "cc", now - 120)
|
||||
dp.insert_message(db, channel_message(23, sender, "#{name}: hello", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[23, sender]])
|
||||
expect(last_heard(db, retired)).to eq(now - 40 * day)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps each of two live same-name devices on its own messages" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, "!aaaa0001", "Twin", "aa", now - 30)
|
||||
seed_keyed_node(db, "!bbbb0001", "Twin", "bb", now - 600)
|
||||
dp.insert_message(db, channel_message(24, "!aaaa0001", "Twin: from device A", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[24, "!aaaa0001"]])
|
||||
expect(last_heard(db, "!bbbb0001")).to eq(now - 600)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "does not move the sender's earlier messages" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
seed_keyed_node(db, sender, name, "cc", now - 120)
|
||||
seed_message(db, 25, sender, "#{name}: old one", now - 7200)
|
||||
seed_message(db, 26, sender, "#{name}: old two", now - 7100)
|
||||
dp.insert_message(db, channel_message(27, sender, "#{name}: new", now))
|
||||
expect(attributions(db)).to eq([[25, sender], [26, sender], [27, sender]])
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "makes merge_into_real_node a no-op when given a real node id" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
seed_keyed_node(db, sender, name, "cc", now - 120)
|
||||
seed_message(db, 28, sender, "#{name}: mine", now - 60)
|
||||
dp.merge_into_real_node(db, sender, name)
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[28, sender]])
|
||||
expect(last_heard(db, retired)).to eq(now - 60 * day)
|
||||
expect(last_heard(db, sender)).to eq(now - 120)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
|
||||
describe "sole copy naming a positively stale key (GN3)" do
|
||||
let(:name) { "Carol" }
|
||||
let(:live) { "!cccc0001" }
|
||||
let(:retired) { "!dddd0001" }
|
||||
|
||||
it "credits the one live same-name node and leaves the stale one frozen" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
seed_keyed_node(db, live, name, "cc", now - 120)
|
||||
dp.insert_message(db, channel_message(30, retired, "#{name}: via a stale roster", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[30, live]])
|
||||
expect(last_heard(db, live)).to eq(now)
|
||||
expect(last_heard(db, retired)).to eq(now - 60 * day)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps the stale sender when no same-name node is live" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
dp.insert_message(db, channel_message(31, retired, "#{name}: still me", now))
|
||||
aggregate_failures do
|
||||
expect(attributions(db)).to eq([[31, retired]])
|
||||
expect(last_heard(db, retired)).to eq(now)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps the stale sender when two same-name nodes are live" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, retired, name, "dd", now - 60 * day)
|
||||
seed_keyed_node(db, live, name, "cc", now - 120)
|
||||
seed_keyed_node(db, "!eeee0001", name, "ee", now - 600)
|
||||
dp.insert_message(db, channel_message(32, retired, "#{name}: ambiguous", now))
|
||||
expect(attributions(db)).to eq([[32, retired]])
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps a sender with no keyed evidence on its own id" do
|
||||
db = open_db
|
||||
seed_unkeyed_node(db, retired, name, now - 40 * day)
|
||||
seed_keyed_node(db, live, name, "cc", now - 120)
|
||||
dp.insert_message(db, channel_message(33, retired, "#{name}: unproven", now))
|
||||
expect(attributions(db)).to eq([[33, retired]])
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
|
||||
describe "ingest API (GN1, GN4)" do
|
||||
let(:app) { Sinatra::Application }
|
||||
let(:auth_headers) do
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer ghost-spec-token" }
|
||||
end
|
||||
|
||||
before do
|
||||
@original_token = ENV["API_TOKEN"]
|
||||
ENV["API_TOKEN"] = "ghost-spec-token"
|
||||
PotatoMesh::App::ApiCache.invalidate_all
|
||||
end
|
||||
|
||||
after do
|
||||
if @original_token.nil?
|
||||
ENV.delete("API_TOKEN")
|
||||
else
|
||||
ENV["API_TOKEN"] = @original_token
|
||||
end
|
||||
end
|
||||
|
||||
# @param path [String] GET route.
|
||||
# @return [Object] the parsed JSON body.
|
||||
def get_json(path)
|
||||
get path
|
||||
JSON.parse(last_response.body)
|
||||
end
|
||||
|
||||
it "neither lists nor counts a mention-only name" do
|
||||
post "/api/messages", [channel_message(40, erin, "Erin: hi @[Ghosty]", now)].to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
names = get_json("/api/nodes").map { |node| node["long_name"] }
|
||||
hour_nodes = get_json("/api/stats").dig("total", "nodes", "hour")
|
||||
aggregate_failures do
|
||||
expect(names).to eq(["Erin"])
|
||||
expect(hour_nodes).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it "answers 201 to user.synthetic entries and writes nothing" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, "!dddd0002", "Dave", "d2", now - 3 * day)
|
||||
db.close
|
||||
# What older ingestors queue for an unrostered sender or mention.
|
||||
placeholder = lambda do |long_name|
|
||||
{
|
||||
"lastHeard" => now, "protocol" => "meshcore",
|
||||
"user" => { "longName" => long_name, "shortName" => "", "role" => "COMPANION", "synthetic" => true },
|
||||
}
|
||||
end
|
||||
payload = {
|
||||
derived_id("Dave") => placeholder.call("Dave"),
|
||||
derived_id("Ghosty") => placeholder.call("Ghosty"),
|
||||
"ingestor" => "!634069bc",
|
||||
"protocol" => "meshcore",
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
db = open_db
|
||||
aggregate_failures do
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(last_heard(db, "!dddd0002")).to eq(now - 3 * day)
|
||||
expect(db.get_first_value("SELECT COUNT(*) FROM nodes WHERE synthetic = 1")).to eq(0)
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
it "keeps a live same-name sender's messages and leaves the quiet node unlisted" do
|
||||
db = open_db
|
||||
seed_keyed_node(db, "!dddd0001", "Carol", "dd", now - 8 * day)
|
||||
seed_keyed_node(db, "!cccc0001", "Carol", "cc", now - 120)
|
||||
db.close
|
||||
post "/api/messages", [channel_message(41, "!cccc0001", "Carol: hello", now)].to_json, auth_headers
|
||||
ids = get_json("/api/nodes").map { |node| node["node_id"] }
|
||||
senders = get_json("/api/messages").map { |message| message["from_id"] }
|
||||
aggregate_failures do
|
||||
# Last advertised 8 days ago: outside the 7-day list unless revived.
|
||||
expect(ids).to eq(["!cccc0001"])
|
||||
expect(senders).to eq(["!cccc0001"])
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user