mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 09:52:02 +02:00
web: fix meshcore node synthisation, merging, and deduplication (#808)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user