web: fix meshcore node synthisation, merging, and deduplication (#808)

This commit is contained in:
l5y
2026-06-22 15:54:12 +02:00
committed by GitHub
parent 088907345d
commit d323cab32a
5 changed files with 451 additions and 1 deletions
+203
View File
@@ -2148,4 +2148,207 @@ RSpec.describe PotatoMesh::App::DataProcessing do
expect(helper.send(:normalize_protocol_value, 42)).to be_nil
end
end
# ---------------------------------------------------------------------------
# insert_message — MeshCore synthetic chat nodes (issue #803).
#
# A MeshCore channel message encodes its sender as a "Name: body" text prefix
# (and quotes/mentions as @[Name]). The sender's from_id is a name-derived
# synthetic id. The placeholder node must be named from that text and marked
# synthetic so it (a) shows the real name and (b) reconciles with the real
# contact via the existing merge machinery — never a generic "Meshcore <hex>"
# stand-in that is mis-recorded as a real (synthetic=0) node.
# ---------------------------------------------------------------------------
describe "#insert_message — meshcore synthetic chat nodes (issue #803)" do
include_context "with isolated db"
# derive("DWeb 0229"): the same id the Python ingestor and JS frontend
# compute, so all three converge on one node row.
let(:sender_synth_id) { "!0f6de6b3" }
def node_for(db, id)
db.execute(
"SELECT node_id, long_name, synthetic FROM nodes WHERE node_id = ?",
[id],
).first
end
def meshcore_channel_message(overrides = {})
{
"id" => 4242,
"rx_time" => now,
"from_id" => sender_synth_id,
"to_id" => "^all",
"channel" => 6,
"text" => "DWeb 0229: flashed DWeb 0229",
"portnum" => "TEXT_MESSAGE_APP",
"protocol" => "meshcore",
"ingestor" => "!634069bc",
}.merge(overrides)
end
it "names the sender placeholder from the chat prefix, not a generic Meshcore <hex>" do
db = open_db
dp.insert_message(db, meshcore_channel_message)
row = node_for(db, sender_synth_id)
expect(row["long_name"]).to eq("DWeb 0229")
expect(row["synthetic"]).to eq(1)
ensure
db&.close
end
it "links the message to an existing real node of the same name rather than synthesizing a duplicate" do
db = open_db
# The real contact is already on record under its pubkey-derived id.
dp.upsert_node(db, "!02294310", {
"lastHeard" => now - 10,
"user" => { "longName" => "DWeb 0229", "shortName" => "D0", "role" => "COMPANION" },
}, protocol: "meshcore")
dp.insert_message(db, meshcore_channel_message)
# The synthetic placeholder is merged away and the message redirected.
expect(node_for(db, sender_synth_id)).to be_nil
from_id = db.get_first_value("SELECT from_id FROM messages WHERE id = 4242")
expect(from_id).to eq("!02294310")
ensure
db&.close
end
it "repairs a pre-existing generic 'Meshcore <hex>' placeholder once a message names the sender" do
db = open_db
# The broken state observed in production: a generic, synthetic=0 stand-in.
dp.upsert_node(db, sender_synth_id, {
"lastHeard" => now - 100,
"protocol" => "meshcore",
"user" => { "longName" => "Meshcore E6B3", "shortName" => "", "role" => "COMPANION" },
})
dp.insert_message(db, meshcore_channel_message)
row = node_for(db, sender_synth_id)
expect(row["long_name"]).to eq("DWeb 0229")
expect(row["synthetic"]).to eq(1)
ensure
db&.close
end
it "repairs a generic placeholder even when the naming message is older than it (out-of-order)" do
db = open_db
# The placeholder was last heard AFTER the naming message's rx_time — the
# rename must still land (not be gated by a last_heard guard) so the row is
# never left demoted-but-still-generically-named.
dp.upsert_node(db, sender_synth_id, {
"lastHeard" => now,
"user" => { "longName" => "Meshcore E6B3", "shortName" => "", "role" => "COMPANION" },
}, protocol: "meshcore")
dp.insert_message(db, meshcore_channel_message("rx_time" => now - 500))
row = node_for(db, sender_synth_id)
expect(row["long_name"]).to eq("DWeb 0229")
expect(row["synthetic"]).to eq(1)
ensure
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
# be read as a sender prefix, so the generic placeholder path is used.
dp.insert_message(db, meshcore_channel_message(
"id" => 4244,
"from_id" => "!11112222",
"to_id" => "!aabbccdd",
"text" => "note: buy milk",
))
row = node_for(db, "!11112222")
expect(dp.generic_fallback_name?(row["long_name"], "!11112222", "meshcore")).to be(true)
expect(row["synthetic"]).to eq(0)
ensure
db&.close
end
it "falls back to the generic placeholder when a channel message has no sender prefix" do
db = open_db
dp.insert_message(db, meshcore_channel_message(
"id" => 4245,
"from_id" => "!33334444",
"text" => "hello with no colon",
))
row = node_for(db, "!33334444")
expect(dp.generic_fallback_name?(row["long_name"], "!33334444", "meshcore")).to be(true)
expect(row["synthetic"]).to eq(0)
ensure
db&.close
end
it "leaves a genuine real node untouched when its name is referenced" do
db = open_db
dp.upsert_node(db, "!55556666", {
"lastHeard" => now,
"user" => { "longName" => "Real Companion", "shortName" => "RC", "role" => "COMPANION" },
}, protocol: "meshcore")
dp.ensure_meshcore_chat_node(db, "!55556666", "Real Companion", now)
row = node_for(db, "!55556666")
expect(row["long_name"]).to eq("Real Companion")
expect(row["synthetic"]).to eq(0)
ensure
db&.close
end
it "is a no-op when the node id or name is missing" do
db = open_db
dp.ensure_meshcore_chat_node(db, nil, "Nobody", now)
dp.ensure_meshcore_chat_node(db, "!77778888", nil, now)
expect(db.get_first_value("SELECT COUNT(*) FROM nodes").to_i).to eq(0)
ensure
db&.close
end
end
# ---------------------------------------------------------------------------
# MeshCore chat text parsing & id derivation (issue #803).
# ---------------------------------------------------------------------------
describe "meshcore chat text parsing" do
it "parses the sender name before the first colon" do
expect(dp.parse_meshcore_sender_name("DWeb 0229: flashed DWeb 0229")).to eq("DWeb 0229")
# Only the first colon splits; later colons stay in the body.
expect(dp.parse_meshcore_sender_name("RS 26: 12:34 done")).to eq("RS 26")
end
it "returns nil when there is no colon or the name is blank" do
expect(dp.parse_meshcore_sender_name("no colon here")).to be_nil
expect(dp.parse_meshcore_sender_name(" : body")).to be_nil
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