web: reference meshcore nodes in chat (#709)

* web: reference meshcore nodes in chat

* data: add adv_name to messages

* web: address review comments

* derive actual companion from name string

* derive actual companion from name string

* derive actual companion from name string

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-04-06 13:39:00 +02:00
committed by GitHub
parent 406fa80dd0
commit d8b80c2a97
17 changed files with 1522 additions and 164 deletions
+153 -1
View File
@@ -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:
+3 -1
View File
@@ -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);
+380 -13
View File
@@ -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"](
@@ -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
@@ -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(
@@ -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?
@@ -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] ?? '');
}
@@ -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
@@ -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('@&#x5B;Alice&#x5D;') || html.includes('@&#91;Alice&#93;') || 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');
});
});
@@ -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);
});
@@ -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: '<b> @[Alice]' },
escapeHtml: v => v.replace(/</g, '&lt;').replace(/>/g, '&gt;'),
renderEmojiHtml: emoji,
renderMentionHtml: badge,
});
assert.equal(body, '&lt;b&gt; 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)');
});
+82 -5
View File
@@ -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 `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
// 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, '&nbsp;');
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,
@@ -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<string, object>} 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;
}
+38 -5
View File
@@ -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) {
+197
View File
@@ -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
+5 -5
View File
@@ -180,27 +180,27 @@ RSpec.describe PotatoMesh::App::Helpers do
it "returns the first emoji from the SMP range (U+1F000U+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+2600U+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+2B00U+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
+1 -1
View File
@@ -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