web,data: stop MeshCore chat text from minting or reviving nodes (#883) (#912)

This commit is contained in:
Afri authored and GitHub committed 2026-10-04 16:41:58 +02:00
1 parent a59bd33e02
commit b144b44e82
14 files changed
+665 -301

No files matched your search

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