diff --git a/data/mesh_ingestor/protocols/meshcore.py b/data/mesh_ingestor/protocols/meshcore.py index 215239d..dd8360a 100644 --- a/data/mesh_ingestor/protocols/meshcore.py +++ b/data/mesh_ingestor/protocols/meshcore.py @@ -45,6 +45,7 @@ import asyncio import base64 import hashlib import json +import re import threading import time from datetime import datetime, timezone @@ -204,6 +205,94 @@ def _meshcore_adv_type_to_role(adv_type: object) -> str | None: return _MESHCORE_ADV_TYPE_ROLE.get(adv_type) +def _parse_sender_name(text: str) -> str | None: + """Extract the sender name from a MeshCore channel message text. + + MeshCore channel messages use the convention ``"SenderName: body"``. + Only the first colon is treated as the separator; colons that appear in the + body are preserved. The sender name is stripped of leading and trailing + whitespace. + + Parameters: + text: Raw message text as stored in the database. + + Returns: + Stripped sender name string, or ``None`` when the text does not + contain a colon or the portion before the colon is blank. + """ + colon_idx = text.find(":") + if colon_idx < 0: + return None + name = text[:colon_idx].strip() + return name if name else None + + +# Matches @[Name] mention patterns in MeshCore message bodies. +_MENTION_RE = re.compile(r"@\[([^\]]+)\]") + + +def _derive_synthetic_node_id(long_name: str) -> str: + """Derive a deterministic synthetic ``!xxxxxxxx`` node ID from a long name. + + Uses the first four bytes of SHA-256(UTF-8 encoded name), formatted as + ``!xxxxxxxx``. The same long name always produces the same ID across + restarts. The probability of collision with a real public-key-derived ID + is ~1 in 4 billion per pair, which is negligible in practice. + + Parameters: + long_name: Node long name used as the hash input. + + Returns: + Canonical ``!xxxxxxxx`` node ID string. + """ + return "!" + hashlib.sha256(long_name.encode("utf-8")).hexdigest()[:8] + + +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, + }, + } + + +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 _pubkey_prefix_to_node_id(contacts: dict, pubkey_prefix: str) -> str | None: """Look up a canonical node ID by six-byte public-key prefix. @@ -371,6 +460,12 @@ 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() # ------------------------------------------------------------------ # Contact management (called from the asyncio thread) @@ -417,6 +512,32 @@ class _MeshcoreInterface: with self._contacts_lock: return _pubkey_prefix_to_node_id(self._contacts, pubkey_prefix) + def lookup_node_id_by_name(self, adv_name: str) -> str | None: + """Return the canonical node ID for the contact whose ``adv_name`` matches. + + Used to resolve the sender of a MeshCore channel message from the + ``"SenderName: body"`` text prefix when no ``pubkey_prefix`` is + available in the event payload. The comparison is case-sensitive + because ``adv_name`` values come verbatim from the MeshCore firmware. + + Parameters: + adv_name: Advertised name to look up. Leading and trailing + whitespace is stripped before comparison. + + Returns: + Canonical ``!xxxxxxxx`` node ID, or ``None`` when no contact with + that name is known. + """ + name = adv_name.strip() if adv_name else "" + if not name: + return None + 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 + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -657,11 +778,40 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict: rx_time = int(time.time()) channel_idx = payload.get("channel_idx", 0) + # 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. + 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) + packet = { "id": _derive_message_id(sender_ts, f"c{channel_idx}", text), "rxTime": rx_time, "rx_time": rx_time, - "from_id": None, + "from_id": from_id, "to_id": "^all", "channel": channel_idx, "snr": payload.get("SNR"), @@ -679,6 +829,8 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict: "MeshCore channel message", context="meshcore.channel_msg", channel=channel_idx, + sender=sender_name, + from_id=from_id, ) async def on_contact_msg(evt) -> None: diff --git a/data/nodes.sql b/data/nodes.sql index abf4458..d3d4435 100644 --- a/data/nodes.sql +++ b/data/nodes.sql @@ -42,9 +42,11 @@ CREATE TABLE IF NOT EXISTS nodes ( altitude REAL, lora_freq INTEGER, modem_preset TEXT, - protocol TEXT NOT NULL DEFAULT 'meshtastic' + protocol TEXT NOT NULL DEFAULT 'meshtastic', + synthetic BOOLEAN NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard); CREATE INDEX IF NOT EXISTS idx_nodes_hw_model ON nodes(hw_model); CREATE INDEX IF NOT EXISTS idx_nodes_latlon ON nodes(latitude, longitude); +CREATE INDEX IF NOT EXISTS idx_nodes_long_name ON nodes(long_name); diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index f994456..7227bf3 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -40,18 +40,22 @@ from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup _contact_to_node_dict, _derive_message_id, _derive_modem_preset, + _derive_synthetic_node_id, _ensure_channel_names, + _extract_mention_names, _make_connection, _make_event_handlers, _meshcore_adv_type_to_role, _meshcore_node_id, _meshcore_short_name, + _parse_sender_name, _process_contact_update, _process_contacts, _process_self_info, _pubkey_prefix_to_node_id, _record_meshcore_message, _self_info_to_node_dict, + _synthetic_node_dict, _to_json_safe, ) @@ -578,6 +582,182 @@ def test_pubkey_prefix_returns_none_for_empty_contacts(): assert _pubkey_prefix_to_node_id({}, "aabbccddee11") is None +# --------------------------------------------------------------------------- +# _parse_sender_name +# --------------------------------------------------------------------------- + + +def test_parse_sender_name_typical(): + """Returns the name portion of 'SenderName: body' text.""" + assert _parse_sender_name("T114-Zeh: Hello world") == "T114-Zeh" + + +def test_parse_sender_name_trims_whitespace(): + """Leading and trailing whitespace is stripped from the sender name.""" + assert _parse_sender_name(" Alice : body ") == "Alice" + + +def test_parse_sender_name_body_may_contain_colons(): + """Only the first colon separates sender from body; body colons are kept.""" + assert _parse_sender_name("BGruenauBot: ack | 80,42,68 (3 hops)") == "BGruenauBot" + + +def test_parse_sender_name_no_colon_returns_none(): + """Returns None when the text contains no colon.""" + assert _parse_sender_name("no colon here") is None + + +def test_parse_sender_name_empty_string_returns_none(): + """Returns None for an empty string.""" + assert _parse_sender_name("") is None + + +def test_parse_sender_name_colon_first_returns_none(): + """Returns None when the colon is the first character (empty sender).""" + assert _parse_sender_name(":body") is None + + +def test_parse_sender_name_whitespace_only_before_colon_returns_none(): + """Returns None when only whitespace appears before the colon.""" + assert _parse_sender_name(" : body") is None + + +# --------------------------------------------------------------------------- +# _derive_synthetic_node_id +# --------------------------------------------------------------------------- + + +def test_derive_synthetic_node_id_format(): + """Synthetic node ID must start with ! and have eight hex chars.""" + nid = _derive_synthetic_node_id("Alice") + assert nid.startswith("!") + assert len(nid) == 9 + assert all(c in "0123456789abcdef" for c in nid[1:]) + + +def test_derive_synthetic_node_id_deterministic(): + """Same long name always produces the same node ID.""" + assert _derive_synthetic_node_id("Alice") == _derive_synthetic_node_id("Alice") + + +def test_derive_synthetic_node_id_distinct_names(): + """Different long names produce different node IDs.""" + assert _derive_synthetic_node_id("Alice") != _derive_synthetic_node_id("Bob") + + +def test_derive_synthetic_node_id_unicode(): + """Unicode names produce valid IDs.""" + nid = _derive_synthetic_node_id("pete 🍁") + assert nid.startswith("!") + 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 +# --------------------------------------------------------------------------- + + +def test_lookup_node_id_by_name_finds_exact_match(): + """Returns the node ID when a contact with the given adv_name exists.""" + iface = _MeshcoreInterface(target=None) + pub_key = "aabbccdd" + "00" * 28 + iface._update_contact({"public_key": pub_key, "adv_name": "Alice"}) + assert iface.lookup_node_id_by_name("Alice") == "!aabbccdd" + + +def test_lookup_node_id_by_name_trims_query(): + """Strips whitespace from the query before comparing.""" + iface = _MeshcoreInterface(target=None) + pub_key = "aabbccdd" + "00" * 28 + iface._update_contact({"public_key": pub_key, "adv_name": "Alice"}) + assert iface.lookup_node_id_by_name(" Alice ") == "!aabbccdd" + + +def test_lookup_node_id_by_name_case_sensitive_mismatch(): + """Returns None for a case-insensitive match — comparison is case-sensitive.""" + iface = _MeshcoreInterface(target=None) + pub_key = "aabbccdd" + "00" * 28 + iface._update_contact({"public_key": pub_key, "adv_name": "Alice"}) + assert iface.lookup_node_id_by_name("alice") is None + + +def test_lookup_node_id_by_name_no_contacts(): + """Returns None when no contacts are registered.""" + iface = _MeshcoreInterface(target=None) + assert iface.lookup_node_id_by_name("Alice") is None + + +def test_lookup_node_id_by_name_empty_string(): + """Returns None for an empty name query.""" + iface = _MeshcoreInterface(target=None) + pub_key = "aabbccdd" + "00" * 28 + iface._update_contact({"public_key": pub_key, "adv_name": "Alice"}) + assert iface.lookup_node_id_by_name("") is None + + +def test_lookup_node_id_by_name_none_query(): + """Returns None when adv_name argument is None.""" + iface = _MeshcoreInterface(target=None) + assert iface.lookup_node_id_by_name(None) is None + + +def test_lookup_node_id_by_name_multiple_contacts(): + """Returns the correct node ID when multiple contacts are registered.""" + iface = _MeshcoreInterface(target=None) + iface._update_contact({"public_key": "11111111" + "00" * 28, "adv_name": "Alpha"}) + iface._update_contact({"public_key": "22222222" + "00" * 28, "adv_name": "Beta"}) + assert iface.lookup_node_id_by_name("Alpha") == "!11111111" + assert iface.lookup_node_id_by_name("Beta") == "!22222222" + + # --------------------------------------------------------------------------- # _meshcore_adv_type_to_role # --------------------------------------------------------------------------- @@ -829,26 +1009,56 @@ def _make_stub_handlers_module(): return mod -def test_on_channel_msg_queues_packet(monkeypatch): - """on_channel_msg must call store_packet_dict with the correct packet fields.""" - import asyncio +class _FakeEvt: + """Minimal stand-in for a MeshCore SDK event object used in handler tests.""" + + def __init__(self, payload): + self.payload = payload + + +def _setup_channel_msg_handlers(monkeypatch, *, contacts=None): + """Set up the patched handler environment for ``CHANNEL_MSG_RECV`` tests. + + Patches the debug logger and the ``handlers`` module reference so that + :func:`_make_event_handlers` can be called without a real connection. + + Parameters: + monkeypatch: pytest monkeypatch fixture. + contacts: Optional list of contact dicts to pre-register on the + returned interface, e.g. ``[{"public_key": "aabb…", "adv_name": "Alice"}]``. + + Returns: + Tuple of ``(captured, upserted, iface, hmap)`` where *captured* is the + list of packets passed to ``store_packet_dict``, *upserted* is the list + of ``(node_id, node_dict)`` pairs passed to ``upsert_node``, *iface* is + the :class:`_MeshcoreInterface` instance, and *hmap* is the event + handler map returned by :func:`_make_event_handlers`. + """ import data.mesh_ingestor as _mesh_pkg import data.mesh_ingestor.protocols.meshcore as _mod captured: list = [] + upserted: list = [] stub = _make_stub_handlers_module() stub.store_packet_dict = lambda pkt: captured.append(pkt) + stub.upsert_node = lambda node_id, node_dict: upserted.append((node_id, node_dict)) monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None) - # _make_event_handlers does `from .. import handlers`; patch the package attr - # so the deferred import resolves to our stub without touching sys.modules. monkeypatch.setattr(_mesh_pkg, "handlers", stub) - class _FakeEvt: - def __init__(self, payload): - self.payload = payload - iface = _MeshcoreInterface(target=None) + for contact in contacts or []: + iface._update_contact(contact) hmap = _make_event_handlers(iface, "/dev/ttyUSB0") + return captured, upserted, iface, hmap + + +def test_on_channel_msg_queues_packet(monkeypatch): + """on_channel_msg must call store_packet_dict with the correct packet fields.""" + import asyncio + + # _make_event_handlers does `from .. import handlers`; _setup_channel_msg_handlers + # patches the package attribute so the deferred import resolves to a stub. + captured, _upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch) asyncio.run( hmap["CHANNEL_MSG_RECV"]( _FakeEvt( @@ -868,6 +1078,7 @@ def test_on_channel_msg_queues_packet(monkeypatch): assert pkt["decoded"]["text"] == "hello mesh" assert pkt["channel"] == 2 assert pkt["to_id"] == "^all" + # Text has no "SenderName:" prefix so from_id cannot be resolved. assert pkt["from_id"] is None assert pkt["snr"] == 5 assert pkt["rssi"] == -80 @@ -875,6 +1086,166 @@ def test_on_channel_msg_queues_packet(monkeypatch): assert pkt["id"] == _derive_message_id(1_758_000_000, "c2", "hello mesh") +def test_on_channel_msg_resolves_from_id_via_sender_name(monkeypatch): + """on_channel_msg sets from_id when sender name matches a known contact.""" + import asyncio + + pub_key = "aabbccdd" + "00" * 28 + captured, _upserted, _iface, hmap = _setup_channel_msg_handlers( + monkeypatch, + contacts=[{"public_key": pub_key, "adv_name": "T114-Zeh"}], + ) + asyncio.run( + hmap["CHANNEL_MSG_RECV"]( + _FakeEvt( + { + "sender_timestamp": 1_758_000_002, + "text": "T114-Zeh: Test message", + "channel_idx": 0, + "SNR": 7, + "RSSI": -70, + } + ) + ) + ) + + assert len(captured) == 1 + pkt = captured[0] + assert pkt["decoded"]["text"] == "T114-Zeh: Test message" + assert pkt["to_id"] == "^all" + # Sender resolved from contacts via name prefix — no synthetic upsert needed. + 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.""" + 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, + } + ) + ) + ) + + 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 + + +def test_on_channel_msg_no_synthetic_when_no_sender_prefix(monkeypatch): + """on_channel_msg leaves from_id None when text has no SenderName: prefix.""" + import asyncio + + captured, upserted, _iface, hmap = _setup_channel_msg_handlers(monkeypatch) + asyncio.run( + hmap["CHANNEL_MSG_RECV"]( + _FakeEvt( + { + "sender_timestamp": 1_758_000_004, + "text": "no colon here", + "channel_idx": 0, + } + ) + ) + ) + + assert len(captured) == 1 + assert captured[0]["from_id"] is None + 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.""" + 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_005, + "text": "Alice: Hey @[Bob] and @[Carol]", + "channel_idx": 0, + } + ) + ) + ) + + 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 + + +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.""" + import asyncio + from data.mesh_ingestor.protocols.meshcore import _derive_synthetic_node_id + + 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( + { + "sender_timestamp": 1_758_000_006, + "text": "Alice: Hey @[Bob]", + "channel_idx": 0, + } + ) + ) + ) + + # 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 == [] + + def test_on_contact_msg_queues_packet_with_from_id(monkeypatch): """on_contact_msg must resolve from_id via pubkey_prefix and set to_id to host.""" import asyncio @@ -892,10 +1263,6 @@ def test_on_contact_msg_queues_packet_with_from_id(monkeypatch): iface.host_node_id = "!deadbeef" iface._update_contact({"public_key": pub_key, "adv_name": "Alice"}) - class _FakeEvt: - def __init__(self, payload): - self.payload = payload - hmap = _make_event_handlers(iface, "/dev/ttyUSB0") asyncio.run( hmap["CONTACT_MSG_RECV"]( diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index 22cb39b..d46aa30 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -373,12 +373,16 @@ module PotatoMesh lora_freq = coerce_integer(n["lora_freq"] || n["loraFrequency"]) modem_preset = string_or_nil(n["modem_preset"] || n["modemPreset"]) + # Synthetic flag: true for placeholder nodes created from channel message + # sender names before the real contact advertisement is received. + synthetic = user["synthetic"] ? 1 : 0 + long_name = user["longName"] # If the incoming long name is a generic placeholder, prefer any real # name already on record so we never stomp known data with fallback # text. For new nodes there is nothing to preserve, so the generic # name is still written via the INSERT VALUES path. - long_name_conflict_sql = if generic_fallback_name?(user["longName"], node_id, protocol) + long_name_conflict_sql = if generic_fallback_name?(long_name, node_id, protocol) # Generic placeholder: keep any real name already on record. # COALESCE returns nodes.long_name when non-null, otherwise falls # back to the incoming generic — so brand-new nodes still get it. @@ -395,7 +399,7 @@ module PotatoMesh node_id, node_num, user["shortName"], - user["longName"], + long_name, user["macaddr"], user["hwModel"] || n["hwModel"], role, @@ -424,36 +428,82 @@ module PotatoMesh lora_freq, modem_preset, protocol, + synthetic, ] with_busy_retry do - db.execute(<<~SQL, row) - INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite, - hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds, - position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset,protocol) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(node_id) DO UPDATE SET - num=COALESCE(excluded.num, nodes.num), - short_name=COALESCE(excluded.short_name, nodes.short_name), - long_name=#{long_name_conflict_sql}, - macaddr=COALESCE(excluded.macaddr, nodes.macaddr), - hw_model=COALESCE(excluded.hw_model, nodes.hw_model), - role=COALESCE(excluded.role, nodes.role), - public_key=COALESCE(excluded.public_key, nodes.public_key), - is_unmessagable=COALESCE(excluded.is_unmessagable, nodes.is_unmessagable), - is_favorite=excluded.is_favorite, hops_away=excluded.hops_away, snr=excluded.snr, last_heard=excluded.last_heard, - first_heard=COALESCE(nodes.first_heard, excluded.first_heard, excluded.last_heard), - battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization, - air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds, - position_time=COALESCE(excluded.position_time, nodes.position_time), - location_source=COALESCE(excluded.location_source, nodes.location_source), - precision_bits=COALESCE(excluded.precision_bits, nodes.precision_bits), - latitude=COALESCE(excluded.latitude, nodes.latitude), - longitude=COALESCE(excluded.longitude, nodes.longitude), - altitude=COALESCE(excluded.altitude, nodes.altitude), - lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset, - protocol=COALESCE(NULLIF(nodes.protocol,'meshtastic'), excluded.protocol) - WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0) - SQL + db.transaction do + db.execute(<<~SQL, row) + INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite, + hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds, + position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset,protocol,synthetic) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(node_id) DO UPDATE SET + num=COALESCE(excluded.num, nodes.num), + short_name=COALESCE(excluded.short_name, nodes.short_name), + long_name=#{long_name_conflict_sql}, + macaddr=COALESCE(excluded.macaddr, nodes.macaddr), + hw_model=COALESCE(excluded.hw_model, nodes.hw_model), + role=COALESCE(excluded.role, nodes.role), + public_key=COALESCE(excluded.public_key, nodes.public_key), + is_unmessagable=COALESCE(excluded.is_unmessagable, nodes.is_unmessagable), + is_favorite=excluded.is_favorite, hops_away=excluded.hops_away, snr=excluded.snr, last_heard=excluded.last_heard, + first_heard=COALESCE(nodes.first_heard, excluded.first_heard, excluded.last_heard), + battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization, + air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds, + position_time=COALESCE(excluded.position_time, nodes.position_time), + location_source=COALESCE(excluded.location_source, nodes.location_source), + precision_bits=COALESCE(excluded.precision_bits, nodes.precision_bits), + latitude=COALESCE(excluded.latitude, nodes.latitude), + longitude=COALESCE(excluded.longitude, nodes.longitude), + altitude=COALESCE(excluded.altitude, nodes.altitude), + lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset, + protocol=COALESCE(NULLIF(nodes.protocol,'meshtastic'), excluded.protocol), + synthetic=MIN(COALESCE(excluded.synthetic,1), COALESCE(nodes.synthetic,1)) + WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0) + AND NOT (COALESCE(nodes.synthetic,0) = 0 AND excluded.synthetic = 1) + SQL + + # When a real (non-synthetic) node is upserted with a known long + # name, migrate any synthetic placeholder rows that share that name. + # This fires when the MeshCore device finally receives the sender's + # contact advertisement, resolving the placeholder to a real node ID. + if synthetic == 0 && long_name && !long_name.empty? + merge_synthetic_nodes(db, node_id, long_name) + end + end + end + end + + # Migrate messages from synthetic placeholder nodes to a newly confirmed + # real node, then remove the placeholders. + # + # Called inside a transaction from +upsert_node+ when a real (non-synthetic) + # MeshCore node with the same +long_name+ is upserted. + # + # Only +messages.from_id+ is migrated. Synthetic nodes are placeholders + # created solely from parsed channel message sender names, so they cannot + # have associated positions, telemetry, neighbors, or traces — those tables + # are intentionally left untouched. + # + # @param db [SQLite3::Database] open database connection. + # @param real_node_id [String] canonical node ID for the real contact. + # @param long_name [String] long name to match against synthetic rows. + # @return [void] + def merge_synthetic_nodes(db, real_node_id, long_name) + synthetic_ids = db.execute( + "SELECT node_id FROM nodes WHERE long_name = ? AND synthetic = 1 AND protocol = 'meshcore' AND node_id != ?", + [long_name, real_node_id], + ).map { |row| row[0] } + + synthetic_ids.each do |synthetic_id| + db.execute( + "UPDATE messages SET from_id = ? WHERE from_id = ?", + [real_node_id, synthetic_id], + ) + db.execute( + "DELETE FROM nodes WHERE node_id = ? AND synthetic = 1", + [synthetic_id], + ) end end diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index e0fa229..1793743 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -136,6 +136,17 @@ module PotatoMesh db.execute("ALTER TABLE nodes ADD COLUMN protocol TEXT NOT NULL DEFAULT 'meshtastic'") db.execute("UPDATE nodes SET protocol = 'meshtastic' WHERE protocol IS NULL OR TRIM(protocol) = ''") end + + unless node_columns.include?("synthetic") + db.execute("ALTER TABLE nodes ADD COLUMN synthetic BOOLEAN NOT NULL DEFAULT 0") + end + + if node_columns.include?("long_name") + existing_indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='nodes'").flatten + unless existing_indexes.include?("idx_nodes_long_name") + db.execute("CREATE INDEX IF NOT EXISTS idx_nodes_long_name ON nodes(long_name)") + end + end end message_table_exists = db.get_first_value( diff --git a/web/lib/potato_mesh/application/helpers/node_helpers.rb b/web/lib/potato_mesh/application/helpers/node_helpers.rb index 45b1702..21084c6 100644 --- a/web/lib/potato_mesh/application/helpers/node_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/node_helpers.rb @@ -81,7 +81,9 @@ module PotatoMesh # Algorithm (applied in priority order): # 1. If the long name contains an emoji character (see # +MESHCORE_COMPANION_EMOJI_PATTERN+), use the first emoji embedded in a - # 4-character display slot: ``" E "`` (two leading spaces, emoji, space). + # 4-column display slot: ``" E "`` (one leading space, emoji, one trailing + # space). Emoji are rendered double-width in monospace fonts, so one leading + # space keeps the badge at four visual columns. # 2. If the long name contains two or more whitespace-separated words, use # the capitalised first letters of the first two words: ``" XY "``. # 3. If the long name is a single word, use its capitalised first letter: @@ -96,7 +98,9 @@ module PotatoMesh return nil unless name emoji = name.scan(MESHCORE_COMPANION_EMOJI_PATTERN).first - return " #{emoji} " if emoji + # Wide emoji occupies two display columns, so use one leading space and + # one trailing space to stay within the four-column badge width. + return " #{emoji} " if emoji words = name.strip.split(/\s+/).reject(&:empty?) return nil if words.empty? diff --git a/web/public/assets/js/app/__tests__/main-app-test-helpers.js b/web/public/assets/js/app/__tests__/main-app-test-helpers.js new file mode 100644 index 0000000..7fe8994 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-app-test-helpers.js @@ -0,0 +1,78 @@ +/* + * 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. + */ + +import { createDomEnvironment } from './dom-environment.js'; +import { initializeApp } from '../main.js'; + +/** + * Minimal {@link initializeApp} configuration shared across main.js test suites. + * Frozen to prevent accidental mutation between tests. + */ +export const MINIMAL_CONFIG = Object.freeze({ + channel: 'Primary', + frequency: '915MHz', + refreshMs: 0, + refreshIntervalSeconds: 30, + chatEnabled: true, + mapCenter: { lat: 0, lon: 0 }, + mapZoom: null, + maxDistanceKm: 0, + tileFilters: { light: '', dark: '' }, + instancesFeatureEnabled: false, + instanceDomain: null, + snapshotWindowSeconds: 3600, +}); + +/** + * Spin up a minimal DOM environment, call {@link initializeApp} with a stub + * config, and return the inner test utilities alongside a cleanup handle. + * + * @returns {{ testUtils: Object, cleanup: Function }} + */ +export function setupApp() { + const env = createDomEnvironment({ includeBody: true }); + // themeToggle is accessed without a null guard in initializeApp. + env.createElement('button', 'themeToggle'); + const { _testUtils } = initializeApp(MINIMAL_CONFIG); + return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) }; +} + +/** + * Run a test body with a fresh app instance, ensuring cleanup regardless of + * outcome. Eliminates the repetitive try/finally boilerplate across tests. + * + * @param {function(Object): void} fn Receives the _testUtils object. + */ +export function withApp(fn) { + const { testUtils, cleanup } = setupApp(); + try { + fn(testUtils); + } finally { + cleanup(); + } +} + +/** + * Extract the serialised HTML string from a DOM element returned by the test + * utils. The stub environment exposes innerHTML as a plain string; this + * normalises the fallback path for environments where it may not be. + * + * @param {HTMLElement} el + * @returns {string} + */ +export function innerHtml(el) { + return String(typeof el.innerHTML === 'string' ? el.innerHTML : el.childNodes?.[0] ?? ''); +} diff --git a/web/public/assets/js/app/__tests__/main-filter.test.js b/web/public/assets/js/app/__tests__/main-filter.test.js index 714f647..0114000 100644 --- a/web/public/assets/js/app/__tests__/main-filter.test.js +++ b/web/public/assets/js/app/__tests__/main-filter.test.js @@ -17,49 +17,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createDomEnvironment } from './dom-environment.js'; -import { initializeApp } from '../main.js'; - -const MINIMAL_CONFIG = Object.freeze({ - channel: 'Primary', - frequency: '915MHz', - refreshMs: 0, - refreshIntervalSeconds: 30, - chatEnabled: true, - mapCenter: { lat: 0, lon: 0 }, - mapZoom: null, - maxDistanceKm: 0, - tileFilters: { light: '', dark: '' }, - instancesFeatureEnabled: false, - instanceDomain: null, - snapshotWindowSeconds: 3600, -}); - -/** - * Spin up a minimal app and return test utilities with a cleanup handle. - * - * @returns {{ testUtils: Object, cleanup: Function }} - */ -function setupApp() { - const env = createDomEnvironment({ includeBody: true }); - env.createElement('button', 'themeToggle'); - const { _testUtils } = initializeApp(MINIMAL_CONFIG); - return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) }; -} - -/** - * Run a test body with a fresh app instance, ensuring cleanup regardless of outcome. - * - * @param {function(Object): void} fn Receives the _testUtils object. - */ -function withApp(fn) { - const { testUtils, cleanup } = setupApp(); - try { - fn(testUtils); - } finally { - cleanup(); - } -} +import { withApp } from './main-app-test-helpers.js'; // --------------------------------------------------------------------------- // makeRoleFilterKey diff --git a/web/public/assets/js/app/__tests__/main-protocol.test.js b/web/public/assets/js/app/__tests__/main-protocol.test.js index 6abd1bf..d0c3c8a 100644 --- a/web/public/assets/js/app/__tests__/main-protocol.test.js +++ b/web/public/assets/js/app/__tests__/main-protocol.test.js @@ -17,64 +17,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createDomEnvironment } from './dom-environment.js'; -import { initializeApp } from '../main.js'; - -const MINIMAL_CONFIG = Object.freeze({ - channel: 'Primary', - frequency: '915MHz', - refreshMs: 0, - refreshIntervalSeconds: 30, - chatEnabled: true, - mapCenter: { lat: 0, lon: 0 }, - mapZoom: null, - maxDistanceKm: 0, - tileFilters: { light: '', dark: '' }, - instancesFeatureEnabled: false, - instanceDomain: null, - snapshotWindowSeconds: 3600, -}); - -/** - * Spin up a minimal DOM environment, call initializeApp with a stub config, - * and return the inner test utilities alongside an env.cleanup() handle. - * - * @returns {{ testUtils: Object, cleanup: Function }} - */ -function setupApp() { - const env = createDomEnvironment({ includeBody: true }); - // themeToggle is accessed without a null guard in initializeApp. - env.createElement('button', 'themeToggle'); - const { _testUtils } = initializeApp(MINIMAL_CONFIG); - return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) }; -} - -/** - * Run a test body with a fresh app instance, ensuring cleanup regardless of - * outcome. Eliminates the repetitive try/finally boilerplate across tests. - * - * @param {function(Object): void} fn Receives the _testUtils object. - */ -function withApp(fn) { - const { testUtils, cleanup } = setupApp(); - try { - fn(testUtils); - } finally { - cleanup(); - } -} - -/** - * Extract the serialised HTML string from a DOM element returned by the test - * utils. The stub environment exposes innerHTML as a plain string; this - * normalises the fallback path for environments where it may not be. - * - * @param {HTMLElement} el - * @returns {string} - */ -function innerHtml(el) { - return String(typeof el.innerHTML === 'string' ? el.innerHTML : el.childNodes?.[0] ?? ''); -} +import { withApp, innerHtml } from './main-app-test-helpers.js'; // --- buildDisplayContext --- @@ -280,3 +223,126 @@ test('createMessageChatEntry shows meshcore icon for meshcore node', () => { assert.ok(innerHtml(div).includes('meshcore.svg'), 'chat entry for meshcore node should show meshcore icon'); }); }); + +// --- createMessageChatEntry: MeshCore channel message sender resolution --- + +/** + * A MeshCore COMPANION node used as the canonical sender fixture in the + * channel-message tests below. + */ +const T114_ZEH = { node_id: '!aabbccdd', long_name: 'T114-Zeh', short_name: ' T ', role: 'COMPANION', protocol: 'meshcore' }; + +/** + * Build a minimal MeshCore channel message payload for createMessageChatEntry. + * @param {string} text Message text (typically "SenderName: body" format). + * @param {object} [overrides] Properties to merge in. + */ +function makeMeshcoreChannelMsg(text, overrides = {}) { + return { text, rx_time: 1000, protocol: 'meshcore', to_id: '^all', node: null, ...overrides }; +} + +test('createMessageChatEntry: meshcore channel message uses sender node short name when found', () => { + withApp((t) => { + // Seed a node with a known long_name so findNodeByLongName can resolve it. + t.rebuildNodeIndex([T114_ZEH]); + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Hello world')); + const html = innerHtml(div); + // Badge should NOT be the fallback '?' — the node's short_name should be used + assert.ok(html.includes('T'), 'badge should contain T from derived short name'); + assert.ok(!html.includes('?'), 'badge should not show placeholder question mark'); + }); +}); + +test('createMessageChatEntry: meshcore channel message hides sender long name — only body shown', () => { + withApp((t) => { + t.rebuildNodeIndex([T114_ZEH]); + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Hello world')); + const html = innerHtml(div); + // The sender long name is NOT prepended as a link — only the text after the colon is shown + assert.ok(html.includes('Hello world'), 'body text after colon should be rendered'); + // Sender name should not appear as a link (href to node page) in the body + assert.ok(!html.includes('T114-Zeh:'), 'sender long name prefix with colon should not appear in body'); + }); +}); + +test('createMessageChatEntry: meshcore channel message, sender node not found — shows body only', () => { + withApp((t) => { + t.rebuildNodeIndex([]); // empty — no nodes known + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('UnknownSender: Hello')); + const html = innerHtml(div); + // Only the body text is shown; sender name is not prepended as a link + assert.ok(html.includes('Hello'), 'body text after colon should still be rendered'); + assert.ok(!html.includes('UnknownSender:'), 'sender long name prefix with colon should not appear in body'); + assert.ok(!html.includes('/nodes/'), 'should not produce a node link when sender is not found'); + }); +}); + +test('createMessageChatEntry: meshcore channel message, no colon in text — body unchanged', () => { + withApp((t) => { + t.rebuildNodeIndex([T114_ZEH]); + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('no colon here')); + const html = innerHtml(div); + assert.ok(html.includes('no colon here'), 'body text should be rendered as-is when no sender prefix found'); + assert.ok(!html.includes('/nodes/'), 'should not produce a node link when no colon prefix'); + }); +}); + +test('createMessageChatEntry: meshcore message with @[Name] mention resolved to badge', () => { + withApp((t) => { + t.rebuildNodeIndex([ + { ...T114_ZEH, node_id: '!11111111' }, + { node_id: '!22222222', long_name: 'BGruenauBot', short_name: ' BG ', role: 'CLIENT', protocol: 'meshcore' }, + ]); + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('BGruenauBot: ack @[T114-Zeh]', { rx_time: 2000 })); + const html = innerHtml(div); + // The @[T114-Zeh] mention should render as a short-name badge span + assert.ok(html.includes('short-name'), 'mention should produce a short-name badge'); + // The sender long name is not prepended as a link in the body + assert.ok(!html.includes('BGruenauBot:'), 'sender long name prefix with colon should not appear in body'); + }); +}); + +test('createMessageChatEntry: meshcore message with @[Name] mention, node not found — fallback', () => { + withApp((t) => { + t.rebuildNodeIndex([]); + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('EchoBot: Pong! @[Ghost]', { rx_time: 3000 })); + const html = innerHtml(div); + // @[Ghost] mention with no matching node renders as escaped plain text + assert.ok(html.includes('@[Ghost]'), 'unresolved mention should render as escaped @[Name] text'); + }); +}); + +test('createMessageChatEntry: meshcore channel message with hydrated node — body only shown', () => { + // Simulates the case where the ingestor resolved from_id successfully. + // The node is hydrated (m.node is not null), and the body still has "SenderName: body". + withApp((t) => { + t.rebuildNodeIndex([T114_ZEH]); + // node is already hydrated — ingestor resolved from_id via contacts + const div = t.createMessageChatEntry(makeMeshcoreChannelMsg('T114-Zeh: Test message', { rx_time: 5000, node: T114_ZEH })); + const html = innerHtml(div); + // Only the body text after the colon is shown; sender long name is not prepended as a link + assert.ok(html.includes('Test message'), 'body text after colon should be rendered'); + assert.ok(!html.includes('T114-Zeh:'), 'sender long name prefix with colon should not appear in body'); + }); +}); + +test('createMessageChatEntry: meshtastic message with @[Name] is NOT resolved as mention', () => { + withApp((t) => { + t.rebuildNodeIndex([ + { node_id: '!11111111', long_name: 'Alice', short_name: 'ALCE', role: 'CLIENT', protocol: 'meshtastic' }, + ]); + const div = t.createMessageChatEntry({ + text: 'hello @[Alice]', + rx_time: 4000, + protocol: 'meshtastic', + node: { short_name: 'ALCE', role: 'CLIENT', protocol: 'meshtastic' }, + }); + const html = innerHtml(div); + // Meshtastic messages do not process @[Name] — rendered as literal escaped text + assert.ok(html.includes('@[Alice]') || html.includes('@[Alice]') || html.includes('@[Alice]') || html.includes('@[Alice]'), + 'meshtastic @[Name] should be escaped literally, not resolved'); + // Ensure no mention badge was injected (no extra short-name span beyond the sender badge) + const shortNameCount = (html.match(/short-name/g) || []).length; + assert.ok(shortNameCount <= 1, 'only the sender badge should be present, no mention badge'); + }); +}); diff --git a/web/public/assets/js/app/__tests__/meshcore-chat-helpers.test.js b/web/public/assets/js/app/__tests__/meshcore-chat-helpers.test.js new file mode 100644 index 0000000..c101d33 --- /dev/null +++ b/web/public/assets/js/app/__tests__/meshcore-chat-helpers.test.js @@ -0,0 +1,168 @@ +/* + * 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. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + parseMeshcoreSenderPrefix, + findNodeByLongName, +} from '../meshcore-chat-helpers.js'; + +// --------------------------------------------------------------------------- +// parseMeshcoreSenderPrefix +// --------------------------------------------------------------------------- + +test('parseMeshcoreSenderPrefix: typical message', () => { + const result = parseMeshcoreSenderPrefix('T114-Zeh: Hello world'); + assert.deepEqual(result, { senderName: 'T114-Zeh', bodyText: 'Hello world' }); +}); + +test('parseMeshcoreSenderPrefix: trims whitespace around sender and body', () => { + const result = parseMeshcoreSenderPrefix(' Alice : body text '); + assert.deepEqual(result, { senderName: 'Alice', bodyText: 'body text' }); +}); + +test('parseMeshcoreSenderPrefix: empty body after colon', () => { + const result = parseMeshcoreSenderPrefix('Sender:'); + assert.deepEqual(result, { senderName: 'Sender', bodyText: '' }); +}); + +test('parseMeshcoreSenderPrefix: no colon returns null', () => { + assert.equal(parseMeshcoreSenderPrefix('No colon here'), null); +}); + +test('parseMeshcoreSenderPrefix: empty string returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(''), null); +}); + +test('parseMeshcoreSenderPrefix: null input returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(null), null); +}); + +test('parseMeshcoreSenderPrefix: undefined input returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(undefined), null); +}); + +test('parseMeshcoreSenderPrefix: non-string input returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(42), null); +}); + +test('parseMeshcoreSenderPrefix: colon first (empty sender) returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(':body'), null); +}); + +test('parseMeshcoreSenderPrefix: whitespace-only sender returns null', () => { + assert.equal(parseMeshcoreSenderPrefix(' : body'), null); +}); + +test('parseMeshcoreSenderPrefix: only first colon separates sender from body', () => { + const result = parseMeshcoreSenderPrefix('A:B:C'); + assert.deepEqual(result, { senderName: 'A', bodyText: 'B:C' }); +}); + +test('parseMeshcoreSenderPrefix: colons in body are preserved intact', () => { + const result = parseMeshcoreSenderPrefix('BGruenauBot/OBS+: ack @[T114-Zeh] | 80,42,68 (3 hops) | 62.6km'); + assert.deepEqual(result, { + senderName: 'BGruenauBot/OBS+', + bodyText: 'ack @[T114-Zeh] | 80,42,68 (3 hops) | 62.6km', + }); +}); + +test('parseMeshcoreSenderPrefix: sender with slash and plus preserved', () => { + const result = parseMeshcoreSenderPrefix('mEDI | Linux: Pong! T114-Zeh'); + assert.deepEqual(result, { senderName: 'mEDI | Linux', bodyText: 'Pong! T114-Zeh' }); +}); + +// --------------------------------------------------------------------------- +// findNodeByLongName +// --------------------------------------------------------------------------- + +/** Shared single-entry map used across the findNodeByLongName tests below. */ +function makeAliceMap(nodeOverride = {}) { + const node = { node_id: '!aabbccdd', long_name: 'Alice', ...nodeOverride }; + return { node, map: new Map([['!aabbccdd', node]]) }; +} + +test('findNodeByLongName: exact match on snake_case long_name', () => { + const { node, map } = makeAliceMap(); + assert.equal(findNodeByLongName('Alice', map), node); +}); + +test('findNodeByLongName: exact match on camelCase longName', () => { + const node = { node_id: '!aabbccdd', longName: 'Alice', role: 'CLIENT' }; + const map = new Map([['!aabbccdd', node]]); + assert.equal(findNodeByLongName('Alice', map), node); +}); + +test('findNodeByLongName: no match returns null', () => { + const { map } = makeAliceMap(); + assert.equal(findNodeByLongName('Unknown', map), null); +}); + +test('findNodeByLongName: null longName returns null', () => { + const { map } = makeAliceMap(); + assert.equal(findNodeByLongName(null, map), null); +}); + +test('findNodeByLongName: undefined longName returns null', () => { + const { map } = makeAliceMap(); + assert.equal(findNodeByLongName(undefined, map), null); +}); + +test('findNodeByLongName: empty string longName returns null', () => { + const { map } = makeAliceMap(); + assert.equal(findNodeByLongName('', map), null); +}); + +test('findNodeByLongName: non-Map nodesById returns null', () => { + assert.equal(findNodeByLongName('Alice', {}), null); +}); + +test('findNodeByLongName: array nodesById returns null', () => { + assert.equal(findNodeByLongName('Alice', []), null); +}); + +test('findNodeByLongName: empty Map returns null', () => { + assert.equal(findNodeByLongName('Alice', new Map()), null); +}); + +test('findNodeByLongName: case-sensitive — lowercase mismatch returns null', () => { + const { map } = makeAliceMap(); + assert.equal(findNodeByLongName('alice', map), null); +}); + +test('findNodeByLongName: multiple nodes — returns correct one', () => { + const nodeA = { node_id: '!11111111', long_name: 'Alpha' }; + const nodeB = { node_id: '!22222222', long_name: 'Beta' }; + const map = new Map([['!11111111', nodeA], ['!22222222', nodeB]]); + assert.equal(findNodeByLongName('Beta', map), nodeB); + assert.equal(findNodeByLongName('Alpha', map), nodeA); +}); + +test('findNodeByLongName: prefers snake_case when both properties exist', () => { + const node = { node_id: '!aabbccdd', long_name: 'Alice', longName: 'Different' }; + const map = new Map([['!aabbccdd', node]]); + // long_name takes precedence via the ?? chain; should match 'Alice' + assert.equal(findNodeByLongName('Alice', map), node); + assert.equal(findNodeByLongName('Different', map), null); +}); + +test('findNodeByLongName: node with null long_name is skipped', () => { + const node = { node_id: '!aabbccdd', long_name: null }; + const map = new Map([['!aabbccdd', node]]); + assert.equal(findNodeByLongName('Alice', map), null); +}); diff --git a/web/public/assets/js/app/__tests__/message-replies.test.js b/web/public/assets/js/app/__tests__/message-replies.test.js index 9bc4f79..3d079de 100644 --- a/web/public/assets/js/app/__tests__/message-replies.test.js +++ b/web/public/assets/js/app/__tests__/message-replies.test.js @@ -151,3 +151,131 @@ test('buildMessageBody appends reaction counts for REACTION_APP packets without assert.equal(body, 'EMOJI(🌶) ESC(×2)'); }); + +// --------------------------------------------------------------------------- +// buildMessageBody — renderMentionHtml callback +// --------------------------------------------------------------------------- + +// Shared mock helpers reused across the mention-callback tests below. +const esc = v => `ESC(${v})`; +const emoji = v => `EMOJI(${v})`; +const badge = name => `BADGE(${name})`; + +test('buildMessageBody throws TypeError when renderMentionHtml is not a function', () => { + assert.throws( + () => buildMessageBody({ + message: { text: 'hello' }, + escapeHtml: v => v, + renderEmojiHtml: v => v, + renderMentionHtml: 42, + }), + { name: 'TypeError', message: 'renderMentionHtml must be a function when provided' } + ); +}); + +test('buildMessageBody without renderMentionHtml escapes @[Name] literally', () => { + const body = buildMessageBody({ + message: { text: 'hello @[Alice]' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + }); + assert.equal(body, 'ESC(hello @[Alice])'); +}); + +test('buildMessageBody with renderMentionHtml replaces single @[Name] mention', () => { + const body = buildMessageBody({ + message: { text: 'hi @[Alice] there' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: badge, + }); + assert.equal(body, 'ESC(hi )BADGE(Alice)ESC( there)'); +}); + +test('buildMessageBody with renderMentionHtml handles multiple mentions', () => { + const calls = []; + const body = buildMessageBody({ + message: { text: '@[A] and @[B]' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: (name) => { calls.push(name); return `BADGE(${name})`; }, + }); + assert.deepEqual(calls, ['A', 'B']); + assert.equal(body, 'BADGE(A)ESC( and )BADGE(B)'); +}); + +test('buildMessageBody with renderMentionHtml escapes literal segments', () => { + const body = buildMessageBody({ + message: { text: ' @[Alice]' }, + escapeHtml: v => v.replace(//g, '>'), + renderEmojiHtml: emoji, + renderMentionHtml: badge, + }); + assert.equal(body, '<b> BADGE(Alice)'); +}); + +test('buildMessageBody with renderMentionHtml at start of text', () => { + const body = buildMessageBody({ + message: { text: '@[Alice] hello' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: badge, + }); + assert.equal(body, 'BADGE(Alice)ESC( hello)'); +}); + +test('buildMessageBody with renderMentionHtml at end of text', () => { + const body = buildMessageBody({ + message: { text: 'hello @[Alice]' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: badge, + }); + assert.equal(body, 'ESC(hello )BADGE(Alice)'); +}); + +test('buildMessageBody with renderMentionHtml: no mentions, callback not invoked', () => { + let called = false; + const body = buildMessageBody({ + message: { text: 'plain text' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: () => { called = true; return 'BADGE'; }, + }); + assert.equal(called, false); + assert.equal(body, 'ESC(plain text)'); +}); + +test('buildMessageBody with renderMentionHtml: null renderMentionHtml behaves like no callback', () => { + const body = buildMessageBody({ + message: { text: 'hi @[Alice]' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: null, + }); + assert.equal(body, 'ESC(hi @[Alice])'); +}); + +test('buildMessageBody reaction path unaffected by renderMentionHtml', () => { + const reaction = { text: '1', emoji: '👍', portnum: 'REACTION_APP' }; + let called = false; + const body = buildMessageBody({ + message: reaction, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: () => { called = true; return 'BADGE'; }, + }); + assert.equal(called, false); + assert.equal(body, 'EMOJI(👍)'); +}); + +test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal', () => { + const body = buildMessageBody({ + message: { text: 'hello @[unclosed' }, + escapeHtml: esc, + renderEmojiHtml: emoji, + renderMentionHtml: () => 'BADGE', + }); + // @[ without closing ] does not match the pattern — treated as literal + assert.equal(body, 'ESC(hello @[unclosed)'); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index c57470b..b810a1d 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -83,6 +83,7 @@ import { renderChatTabs } from './chat-tabs.js'; import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js'; import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js'; import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js'; +import { parseMeshcoreSenderPrefix, findNodeByLongName } from './meshcore-chat-helpers.js'; import { SNAPSHOT_WINDOW, aggregateNeighborSnapshots, @@ -2002,7 +2003,22 @@ export function initializeApp(config) { if (!short) { return `?   `; } - const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' '); + // Centre the label within a 4-column badge. padStart alone only adds + // leading spaces, producing " C" for a 1-char name with no trailing + // space. Instead distribute padding evenly: 1-char → " C ", 2-char → + // " AB ", 3-char → " ABC", 4-char → unchanged. Names already at 4+ + // chars are left as-is (meshtastic always stores exactly 4; the Ruby + // COMPANION override also produces exactly 4). + const raw = String(short); + let centred; + if (raw.length >= 4) { + centred = raw; + } else { + const leading = Math.ceil((4 - raw.length) / 2); + const trailing = 4 - raw.length - leading; + centred = ' '.repeat(leading) + raw + ' '.repeat(trailing); + } + const padded = escapeHtml(centred).replace(/ /g, ' '); const protocol = nodeData?.protocol ?? null; const color = getRoleColor(roleValue, protocol); const textColor = getRoleTextColor(roleValue, protocol); @@ -3163,8 +3179,40 @@ export function initializeApp(config) { ); const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null; const ts = tsDate ? formatTime(tsDate) : '--:--:--'; - const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node); const messageProtocol = pickFirstProperty([m, m?.node], ['protocol']); + + // MeshCore channel messages use "SenderName: body" text format. The + // ingestor tries to resolve from_id via the contacts roster; when it + // succeeds m.node is hydrated normally. When it fails (contact not yet + // known), m.node is null and we fall back to a name-based lookup here. + // Detection: channel messages always have to_id "^all". + const toId = m.to_id ?? m.toId; + const isMeshcoreChannelMsg = isMeshcoreProtocol(messageProtocol) && toId === '^all'; + + let meshcoreSenderNode = null; + let parsedMeshcorePrefix = null; + if (isMeshcoreChannelMsg && m?.text) { + parsedMeshcorePrefix = parseMeshcoreSenderPrefix(String(m.text)); + // Only attempt the name lookup when the ingestor couldn't resolve the + // sender (m.node is null). If it's already hydrated, m.node is used. + if (parsedMeshcorePrefix && !m.node) { + meshcoreSenderNode = findNodeByLongName(parsedMeshcorePrefix.senderName, nodesById); + } + } + + let short; + if (isMeshcoreChannelMsg && !m.node && meshcoreSenderNode) { + // Fallback: ingestor couldn't resolve sender, but JS found the node by name. + short = renderShortHtml( + meshcoreSenderNode.short_name ?? meshcoreSenderNode.shortName, + meshcoreSenderNode.role, + meshcoreSenderNode.long_name ?? meshcoreSenderNode.longName, + meshcoreSenderNode + ); + } else { + short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node); + } + const nodeProtocolPrefix = protocolIconPrefixHtml(messageProtocol); const replyPrefix = resolveReplyPrefix({ message: m, @@ -3184,11 +3232,39 @@ export function initializeApp(config) { messageBodyHtml = ''; } } else { + // Mention rendering is active for all MeshCore messages (channel + DM): + // @[Name] patterns are replaced with a short-name badge when the named + // node is present in nodesById. + const isMeshcoreMsg = isMeshcoreProtocol(messageProtocol); + const renderMentionHtml = isMeshcoreMsg + ? (mentionedName) => { + const mentionNode = findNodeByLongName(mentionedName, nodesById); + if (mentionNode) { + return renderShortHtml( + mentionNode.short_name ?? mentionNode.shortName, + mentionNode.role, + mentionNode.long_name ?? mentionNode.longName, + mentionNode + ); + } + // Node not found — render as escaped plain text fallback. + return `@[${escapeHtml(mentionedName)}]`; + } + : null; + + // For channel messages, strip the "SenderName: " prefix before building + // the body so we can prepend a linked version of the sender name instead. + const bodyMsg = (isMeshcoreChannelMsg && parsedMeshcorePrefix) + ? { ...m, text: parsedMeshcorePrefix.bodyText } + : m; + messageBodyHtml = buildMessageBody({ - message: m || {}, + message: bodyMsg || {}, escapeHtml, - renderEmojiHtml + renderEmojiHtml, + renderMentionHtml, }); + } const combinedSegments = []; @@ -4650,7 +4726,7 @@ export function initializeApp(config) { * Inner closures exposed for unit tests. Production callers should ignore * this return value. * - * @returns {{ _testUtils: { buildMapPopupHtml: Function, normalizeOverlaySource: Function, createAnnouncementEntry: Function, createMessageChatEntry: Function, buildDisplayContext: Function } }} + * @returns {{ _testUtils: { buildMapPopupHtml: Function, normalizeOverlaySource: Function, createAnnouncementEntry: Function, createMessageChatEntry: Function, buildDisplayContext: Function, rebuildNodeIndex: Function } }} */ return { _testUtils: { @@ -4659,6 +4735,7 @@ export function initializeApp(config) { createAnnouncementEntry, createMessageChatEntry, buildDisplayContext, + rebuildNodeIndex, makeRoleFilterKey, normalizeFilterProtocol, matchesRoleFilter, diff --git a/web/public/assets/js/app/meshcore-chat-helpers.js b/web/public/assets/js/app/meshcore-chat-helpers.js new file mode 100644 index 0000000..e26b0c2 --- /dev/null +++ b/web/public/assets/js/app/meshcore-chat-helpers.js @@ -0,0 +1,67 @@ +/* + * 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. + */ + +/** + * Parse the ``"SenderName: body"`` prefix that MeshCore embeds in channel + * message text. MeshCore channel messages do not carry a sender node ID, so + * the ingestor stores ``from_id = null`` and encodes the sender long name as + * the leading ``"SenderName: "`` prefix of the message text. + * + * Only the first colon is treated as the separator; colons that appear in the + * body are preserved unchanged. + * + * @param {string|null|undefined} text Raw message text from the database. + * @returns {{ senderName: string, bodyText: string }|null} Parsed components, + * or ``null`` when the text does not match the expected format. + */ +export function parseMeshcoreSenderPrefix(text) { + if (text == null || typeof text !== 'string') return null; + const colonIdx = text.indexOf(':'); + if (colonIdx < 0) return null; + const senderName = text.slice(0, colonIdx).trim(); + if (!senderName) return null; + const bodyText = text.slice(colonIdx + 1).trim(); + return { senderName, bodyText }; +} + +/** + * Look up a node in the provided ``nodesById`` Map by its long name. + * + * The comparison is case-sensitive because both the ingestor and the MeshCore + * firmware emit the name verbatim; normalising case would risk false matches + * between nodes whose names differ only in capitalisation. + * + * Both the snake_case (``long_name``) and camelCase (``longName``) property + * variants are checked to accommodate different serialisation paths. + * + * This is an O(n) scan over all nodes. For the typical node counts seen in + * practice (hundreds) this is negligible; a long-name index is not maintained + * in the client-side Map because insertions and lookups occur at different + * phases of the rendering pipeline. + * + * @param {string} longName Long name to search for. + * @param {Map} nodesById Loaded node registry keyed by node ID. + * @returns {object|null} The first matching node, or ``null`` when not found. + */ +export function findNodeByLongName(longName, nodesById) { + if (!longName || typeof longName !== 'string') return null; + if (!(nodesById instanceof Map)) return null; + for (const node of nodesById.values()) { + const candidate = node.long_name ?? node.longName; + if (typeof candidate === 'string' && candidate === longName) return node; + } + return null; +} diff --git a/web/public/assets/js/app/message-replies.js b/web/public/assets/js/app/message-replies.js index 725a7b4..2907c4e 100644 --- a/web/public/assets/js/app/message-replies.js +++ b/web/public/assets/js/app/message-replies.js @@ -338,28 +338,61 @@ function resolveMessageTextSegment(message, isReaction) { return trimmed; } +/** + * Render a text segment, replacing ``@[Name]`` mention patterns with the + * output of ``renderMentionHtml`` when provided. Segments between mentions + * are passed through ``escapeHtml`` to prevent XSS. + * + * When ``renderMentionHtml`` is ``null`` the function behaves identically to + * ``escapeHtml(text)``, preserving backward compatibility. + * + * @param {string} text Raw message text segment. + * @param {Function} escapeHtml HTML-escape function. + * @param {Function|null} renderMentionHtml Called with the mention name (the + * string between ``@[`` and ``]``); should return an HTML snippet. + * @returns {string} HTML string safe for insertion into the DOM. + */ +function renderTextWithMentions(text, escapeHtml, renderMentionHtml) { + if (typeof renderMentionHtml !== 'function') return escapeHtml(text); + // split() with a capturing group interleaves literal segments (even indices) + // and captured mention names (odd indices): ["before", "Alice", "after", ...] + const parts = text.split(/@\[([^\]]+)\]/); + return parts.map((part, i) => { + if (i % 2 === 1) return renderMentionHtml(part); + // Empty literal segments (e.g. when a mention is at the start or end) can + // be skipped to avoid unnecessary escapeHtml calls on empty strings. + return part ? escapeHtml(part) : ''; + }).join(''); +} + /** * Build the rendered message body containing text and optional emoji. * * @param {{ * message: Object, * escapeHtml: Function, - * renderEmojiHtml: Function - * }} params Rendering dependencies. + * renderEmojiHtml: Function, + * renderMentionHtml?: Function|null + * }} params Rendering dependencies. When ``renderMentionHtml`` is provided it + * is called for each ``@[Name]`` mention found in the message text so the + * caller can substitute a badge or link in place of the raw mention string. * @returns {string} HTML snippet describing the message body. */ -export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) { +export function buildMessageBody({ message, escapeHtml, renderEmojiHtml, renderMentionHtml = null }) { if (typeof escapeHtml !== 'function') { throw new TypeError('escapeHtml must be a function'); } if (typeof renderEmojiHtml !== 'function') { throw new TypeError('renderEmojiHtml must be a function'); } + if (renderMentionHtml !== null && typeof renderMentionHtml !== 'function') { + throw new TypeError('renderMentionHtml must be a function when provided'); + } if (!message || typeof message !== 'object') { return ''; } - const segments = []; + const segments = []; const reaction = isReactionMessage(message); const textSegment = resolveMessageTextSegment(message, reaction); const reactionCount = reaction && textSegment && /^×\d+$/.test(textSegment) ? textSegment : null; @@ -368,7 +401,7 @@ export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) { let reactionEmoji = reaction && !emojiIsNumericPlaceholder ? emoji : null; if (!reaction && textSegment) { - segments.push(escapeHtml(textSegment)); + segments.push(renderTextWithMentions(textSegment, escapeHtml, renderMentionHtml)); } if (reaction) { diff --git a/web/spec/data_processing_spec.rb b/web/spec/data_processing_spec.rb index f9b9a79..1b99d28 100644 --- a/web/spec/data_processing_spec.rb +++ b/web/spec/data_processing_spec.rb @@ -372,4 +372,201 @@ RSpec.describe PotatoMesh::App::DataProcessing do db.close end end + + # --------------------------------------------------------------------------- + # upsert_node — synthetic flag + merge + # --------------------------------------------------------------------------- + describe "#upsert_node — synthetic node handling", :db do + include_context "with isolated db" + + let(:now) { Time.now.to_i } + + def seed_message(db, from_id:) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [42, now, "2025-01-01T00:00:00Z", from_id, "^all", "meshcore"], + ) + end + + it "stores synthetic=1 when user.synthetic is true" do + db = open_db + dp.upsert_node(db, "!synth111", { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth111'").first + expect(row[0]).to eq(1) + db.close + end + + it "stores synthetic=0 when user.synthetic is false" do + db = open_db + dp.upsert_node(db, "!real1111", { + "lastHeard" => now, + "protocol" => "meshcore", + "user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => false }, + }, protocol: "meshcore") + row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!real1111'").first + expect(row[0]).to eq(0) + db.close + end + + it "does not overwrite a real node with a synthetic upsert" do + db = open_db + # Insert real node first. + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now - 100, + "user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION" }, + }, protocol: "meshcore") + # Attempt to overwrite with synthetic upsert at a later time. + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now, + "user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!aabbccdd'").first + expect(row[0]).to eq(0) + db.close + end + + it "real wins over synthetic — synthetic=0 is never overwritten by synthetic=1" do + db = open_db + # Insert synthetic first, then real. + dp.upsert_node(db, "!synth222", { + "lastHeard" => now - 200, + "user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + dp.upsert_node(db, "!synth222", { + "lastHeard" => now, + "user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => false }, + }, protocol: "meshcore") + row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth222'").first + expect(row[0]).to eq(0) + db.close + end + + it "migrates messages from synthetic node to real node on name match" do + db = open_db + synth_id = "!synth333" + real_id = "!real3333" + # Create synthetic node and a message from it. + dp.upsert_node(db, synth_id, { + "lastHeard" => now - 500, + "user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + seed_message(db, from_id: synth_id) + # Upsert the real node with the same long name. + dp.upsert_node(db, real_id, { + "lastHeard" => now, + "user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "publicKey" => "cc" * 32 }, + }, protocol: "meshcore") + # Message should now point to the real node. + msg_from = db.execute("SELECT from_id FROM messages WHERE id = 42").first[0] + expect(msg_from).to eq(real_id) + # Synthetic node should be gone. + synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first + expect(synth_row).to be_nil + db.close + end + + it "does not delete real nodes during merge" do + db = open_db + real_id = "!real4444" + # Insert a real node with the same long name as the incoming real node. + dp.upsert_node(db, real_id, { + "lastHeard" => now - 100, + "user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" }, + }, protocol: "meshcore") + # Upsert same node again — should not delete itself. + dp.upsert_node(db, real_id, { + "lastHeard" => now, + "user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" }, + }, protocol: "meshcore") + row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_id]).first + expect(row).not_to be_nil + db.close + end + + it "migrates messages from multiple synthetic nodes to a single real node" do + db = open_db + synth_a = "!synth5a5a" + synth_b = "!synth5b5b" + real_id = "!real5555" + # Two synthetic nodes with the same long name (could happen from two + # ingestors or a race). + dp.upsert_node(db, synth_a, { + "lastHeard" => now - 600, + "user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + dp.upsert_node(db, synth_b, { + "lastHeard" => now - 500, + "user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true }, + }, protocol: "meshcore") + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [51, now - 600, "2025-01-01T00:00:00Z", synth_a, "^all", "meshcore"], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [52, now - 500, "2025-01-01T00:00:00Z", synth_b, "^all", "meshcore"], + ) + # Upsert real node. + dp.upsert_node(db, real_id, { + "lastHeard" => now, + "user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "publicKey" => "ee" * 32 }, + }, protocol: "meshcore") + # Both messages should now reference the real node. + from_ids = db.execute("SELECT from_id FROM messages WHERE id IN (51,52) ORDER BY id").map { |r| r[0] } + expect(from_ids).to all(eq(real_id)) + # Both synthetic nodes gone. + remaining = db.execute("SELECT node_id FROM nodes WHERE node_id IN (?,?)", [synth_a, synth_b]).flatten + expect(remaining).to be_empty + db.close + end + end + + # --------------------------------------------------------------------------- + # merge_synthetic_nodes + # --------------------------------------------------------------------------- + describe "#merge_synthetic_nodes" do + include_context "with isolated db" + + let(:now) { Time.now.to_i } + + it "is a no-op when no synthetic nodes match the long name" do + db = open_db + dp.upsert_node(db, "!real6666", { + "lastHeard" => now - 100, + "user" => { "longName" => "Frank", "shortName" => " F " }, + }, protocol: "meshcore") + # Should not raise and should leave the real node intact. + dp.merge_synthetic_nodes(db, "!real6666", "Frank") + row = db.execute("SELECT node_id FROM nodes WHERE node_id = '!real6666'").first + expect(row).not_to be_nil + db.close + end + + it "does not migrate messages from a synthetic node on a different protocol" do + db = open_db + # A synthetic meshtastic node that happens to share the same long name as + # an incoming real meshcore contact must NOT be merged. + synth_id = "!synth7777" + real_id = "!real7777" + db.execute( + "INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)", + [synth_id, "Grace", "meshtastic", 1, now - 100, now - 100], + ) + db.execute( + "INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)", + [61, now - 100, "2025-01-01T00:00:00Z", synth_id, "^all", "meshtastic"], + ) + dp.merge_synthetic_nodes(db, real_id, "Grace") + # meshtastic synthetic node must be untouched. + synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first + expect(synth_row).not_to be_nil + msg_from = db.execute("SELECT from_id FROM messages WHERE id = 61").first[0] + expect(msg_from).to eq(synth_id) + db.close + end + end end diff --git a/web/spec/helpers/node_helpers_spec.rb b/web/spec/helpers/node_helpers_spec.rb index a54a0fe..d572c97 100644 --- a/web/spec/helpers/node_helpers_spec.rb +++ b/web/spec/helpers/node_helpers_spec.rb @@ -180,27 +180,27 @@ RSpec.describe PotatoMesh::App::Helpers do it "returns the first emoji from the SMP range (U+1F000–U+1FFFF)" do name = "Node \u{1F600}" - expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") end it "returns the first emoji from the misc symbols range (U+2600–U+27BF)" do name = "\u{2600} Sun" - expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2600} ") + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2600} ") end it "returns the first emoji from the arrows range (U+2B00–U+2BFF)" do name = "\u{2B50} Star" - expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2B50} ") + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2B50} ") end it "uses the FIRST emoji when multiple are present" do name = "\u{1F600}\u{1F601} Two" - expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") end it "prefers emoji over initials when both are present" do name = "Alice \u{1F600} Bob" - expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") + expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ") end it "returns the single initial when the name is one word with no emoji" do diff --git a/web/spec/queries_spec.rb b/web/spec/queries_spec.rb index 1f0e40b..450d0bd 100644 --- a/web/spec/queries_spec.rb +++ b/web/spec/queries_spec.rb @@ -457,7 +457,7 @@ RSpec.describe PotatoMesh::App::Queries do end rows = queries.query_nodes(10, node_ref: "!cc000003") row = rows.find { |r| r["node_id"] == "!cc000003" } - expect(row["short_name"]).to eq(" \u{1F600} ") + expect(row["short_name"]).to eq(" \u{1F600} ") end it "does not overwrite short_name when long_name is blank for a COMPANION node" do