diff --git a/cmd/server/db.go b/cmd/server/db.go index 91f01542..35fac20d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1517,6 +1517,170 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) { return traces, nil } +// PacketPathPoint is one hop's position along a packet's resolved relay +// path, for map visualization (public/packet-path-map.js). Lat/Lon are +// nil when that node has never advertised a GPS position -- the caller +// draws a gap rather than guessing. +type PacketPathPoint struct { + PublicKey string `json:"publicKey"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` +} + +// PacketPathObserver is the station that produced the deepest observation +// of a packet path (see GetPacketPath), positioned from its configured +// IATA code the same way the Wardriving tab positions observers -- not a +// stored per-observer lat/lon column. +type PacketPathObserver struct { + Name string `json:"name"` + IATA string `json:"iata,omitempty"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` +} + +// PacketPathResponse is the geographic relay path for one packet hash, +// used to draw it on a map (the ping-bot reply's "View path" link). +type PacketPathResponse struct { + Hash string `json:"hash"` + Hops int `json:"hops"` + Points []PacketPathPoint `json:"points"` + Observer *PacketPathObserver `json:"observer,omitempty"` +} + +// GetPacketPath resolves a packet's DEEPEST observation (the one with the +// most hops -- same "farthest leg" reasoning as the ping-bot reply, see +// pingBotReply's doc comment) to a geographic point sequence: each +// relay's name/role/lat/lon in path order, plus the hearing observer's +// position. A packet can have several observations (heard by more than +// one station, possibly at different hop depths); this always picks the +// one that traveled farthest, since that's the more informative path to +// show on a map. +func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { + if !db.hasResolvedPath { + return nil, fmt.Errorf("resolved_path not available on this server") + } + var querySQL string + if db.isV3 { + querySQL = `SELECT obs.name, obs.iata, o.resolved_path + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx + WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + } else { + querySQL = `SELECT o.observer_name, NULL, o.resolved_path + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''` + } + rows, err := db.conn.Query(querySQL, strings.ToLower(hash)) + if err != nil { + return nil, fmt.Errorf("packet path query: %w", err) + } + defer rows.Close() + + var bestPath []*string + var bestObserverName, bestObserverIATA sql.NullString + for rows.Next() { + var obsName, obsIATA, rpJSON sql.NullString + if err := rows.Scan(&obsName, &obsIATA, &rpJSON); err != nil { + continue + } + if !rpJSON.Valid { + continue + } + rp := unmarshalResolvedPath(rpJSON.String) + if len(rp) > len(bestPath) { + bestPath = rp + bestObserverName, bestObserverIATA = obsName, obsIATA + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("packet path iteration: %w", err) + } + + resp := &PacketPathResponse{Hash: hash, Hops: len(bestPath), Points: []PacketPathPoint{}} + if len(bestPath) == 0 { + return resp, nil + } + + pubkeys := make([]string, 0, len(bestPath)) + for _, pk := range bestPath { + if pk != nil && *pk != "" { + pubkeys = append(pubkeys, *pk) + } + } + type nodeInfo struct { + name string + role string + lat *float64 + lon *float64 + } + nodeByPK := make(map[string]nodeInfo, len(pubkeys)) + if len(pubkeys) > 0 { + placeholders := make([]byte, 0, len(pubkeys)*2) + args := make([]interface{}, len(pubkeys)) + for i, pk := range pubkeys { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = pk + } + nodeRows, err := db.conn.Query( + "SELECT public_key, name, role, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...) + if err == nil { + for nodeRows.Next() { + var pk string + var name, role sql.NullString + var lat, lon sql.NullFloat64 + if nodeRows.Scan(&pk, &name, &role, &lat, &lon) == nil { + ni := nodeInfo{name: name.String, role: role.String} + if lat.Valid { + v := lat.Float64 + ni.lat = &v + } + if lon.Valid { + v := lon.Float64 + ni.lon = &v + } + nodeByPK[pk] = ni + } + } + nodeRows.Close() + } + } + + for _, pk := range bestPath { + if pk == nil || *pk == "" { + continue + } + ni := nodeByPK[*pk] + name := ni.name + if name == "" { + name = *pk + } + resp.Points = append(resp.Points, PacketPathPoint{ + PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon, + }) + } + + if bestObserverName.Valid && bestObserverName.String != "" { + obs := &PacketPathObserver{Name: bestObserverName.String} + if bestObserverIATA.Valid { + obs.IATA = strings.ToUpper(strings.TrimSpace(bestObserverIATA.String)) + if coord, ok := iataCoords[obs.IATA]; ok { + lat, lon := coord.Lat, coord.Lon + obs.Lat, obs.Lon = &lat, &lon + } + } + resp.Observer = obs + } + + return resp, nil +} + // GetChannels returns channel list from GRP_TXT packets. // Queries transmissions directly (not a VIEW) to avoid observation-level // duplicates that could cause stale lastMessage when an older message has @@ -1749,6 +1913,72 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // This avoids loading every observation row for a channel into Go memory // before paginating (issue #1225: 5703 tx × ~50 obs ≈ 275K rows → ~30s // for limit=50). +// channelMentionPrefixRe strips a leading "@target " reply-address the +// same way the frontend does (public/channels.js replyMatch) before +// matching the ping trigger, so "@CoreScopeBot ping" triggers the same as +// a bare "ping". +var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) + +// pingTriggerWords are the exact (case-insensitive) message bodies that +// trigger a pong reply. Mirrored by pingTriggerWords in +// public/channels.js -- keep both lists in sync by hand. +var pingTriggerWords = map[string]bool{ + "ping": true, + "/ping": true, +} + +// isPingTrigger reports whether displayText, after stripping a leading +// "@target " mention the same way the frontend does (public/channels.js +// replyMatch), exactly matches one of pingTriggerWords. +func isPingTrigger(displayText string) bool { + trigger := strings.TrimSpace(displayText) + trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") + return pingTriggerWords[strings.ToLower(strings.TrimSpace(trigger))] +} + +// pingBotReply synthesizes a "pong" reply for a channel message whose +// text matched isPingTrigger — CoreScope-side only, never transmitted +// back onto the mesh (CoreScope has no publish path to a MeshCore +// broker/radio). Purely a read-time annotation over data this message's +// own row already carries (hop count + relay path, SNR, hearing +// observer, region scope), not a persisted message. +// +// repeaterNames is the resolved relay path in hop order (element i is +// hop i's node name, falling back to its pubkey/hash-prefix when a name +// couldn't be resolved); nil/empty when hops == 0 or resolution wasn't +// available -- the hop count itself is unaffected either way. +func pingBotReply(hops int, snr sql.NullFloat64, observer, scope string, repeaterNames []string) map[string]interface{} { + parts := make([]string, 0, 4) + if hops > 0 { + s := "s" + if hops == 1 { + s = "" + } + hopDesc := fmt.Sprintf("%d hop%s", hops, s) + if len(repeaterNames) > 0 { + hopDesc += " (via " + strings.Join(repeaterNames, " → ") + ")" + } + parts = append(parts, hopDesc) + } else { + parts = append(parts, "0 hops (direct)") + } + if snr.Valid { + parts = append(parts, fmt.Sprintf("SNR %.1fdB", snr.Float64)) + } + if observer != "" { + parts = append(parts, "heard by "+observer) + } + if scope != "" { + parts = append(parts, "scope "+scope) + } + return map[string]interface{}{ + "sender": "CoreScopeBot", + "text": "🏓 pong! " + strings.Join(parts, " · "), + "hops": hops, + "snr": nullFloat(snr), + } +} + func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int, error) { if limit <= 0 { limit = 100 @@ -1870,10 +2100,17 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if db.hasScopeName { scopeCol = ", t.scope_name" } + // resolvedPathCol feeds the ping-bot reply's "via RepeaterA → RepeaterB" + // hop names (see the bulk-resolve pass below) -- optional like + // scopeCol since not every DB/test fixture has this column. + resolvedPathCol := "" + if db.hasResolvedPath { + resolvedPathCol = ", o.resolved_path" + } var obsSQL string if db.isV3 { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, - obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + ` + obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id LEFT JOIN observers obs ON obs.rowid = o.observer_idx @@ -1881,7 +2118,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . ORDER BY o.id ASC` } else { obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, - o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + ` + o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `) @@ -1901,9 +2138,26 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } msgMap := make(map[int]*msg, len(pageIDs)) + // pendingPing collects a ping-triggering message's REACH across every + // observation of it, not just the first: hops/snr/resolvedPath track + // the DEEPEST (max-hop) observation seen so far -- how far the packet + // had propagated before the farthest-along observer heard it -- and + // observers is every distinct observer that heard it at all (breadth). + // A single arbitrary "first observation wins" data point understates + // both: two observers can hear the same flood at very different hop + // depths depending on which relay leg reached them. + type pendingPing struct { + hops int + snr sql.NullFloat64 + resolvedPath []*string + observers map[string]bool + scope string + } + pendingPings := make(map[int]*pendingPing) + for rows.Next() { var pktID, txID int - var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString + var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 var obsTs sql.NullInt64 var routeType sql.NullInt64 @@ -1912,17 +2166,54 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } + if db.hasResolvedPath { + scanArgs = append(scanArgs, &resolvedPathJSON) + } if err := rows.Scan(scanArgs...); err != nil { return nil, 0, err } if !dj.Valid { continue } + + // Hop count, relay path, and hearing station for THIS observation + // row -- computed for every row (not just the first) so a ping's + // reach can be tracked across every station that heard it. + var hops int + var entryPrefix string + if pathJSON.Valid { + var h []string + if json.Unmarshal([]byte(pathJSON.String), &h) == nil { + hops = len(h) + if len(h) > 0 { + entryPrefix = h[0] + } + } + } + var resolvedPath []*string + if resolvedPathJSON.Valid { + resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + } + observerName := "" + if obsName.Valid { + observerName = obsName.String + } else if obsID.Valid { + observerName = obsID.String + } + if existing, ok := msgMap[txID]; ok { existing.Repeats++ if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch { existing.LatestEpoch = obsTs.Int64 } + if agg, ok := pendingPings[txID]; ok { + if observerName != "" { + agg.observers[observerName] = true + } + if hops > agg.hops { + agg.hops, agg.snr, agg.resolvedPath = hops, snr, resolvedPath + } + } continue } var decoded map[string]interface{} @@ -1944,17 +2235,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . displayText = text[idx+2:] } } - var hops int - var entryPrefix string - if pathJSON.Valid { - var h []string - if json.Unmarshal([]byte(pathJSON.String), &h) == nil { - hops = len(h) - if len(h) > 0 { - entryPrefix = h[0] - } - } - } senderTs := decoded["sender_timestamp"] m := &msg{ Data: map[string]interface{}{ @@ -1978,14 +2258,72 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if obsTs.Valid { m.LatestEpoch = obsTs.Int64 } - if obsName.Valid { - m.Data["observers"] = []string{obsName.String} - } else if obsID.Valid { - m.Data["observers"] = []string{obsID.String} + if observerName != "" { + m.Data["observers"] = []string{observerName} + } + if isPingTrigger(displayText) { + agg := &pendingPing{hops: hops, snr: snr, resolvedPath: resolvedPath, scope: scopeName.String, observers: map[string]bool{}} + if observerName != "" { + agg.observers[observerName] = true + } + pendingPings[txID] = agg } msgMap[txID] = m } + // Bulk-resolve every pubkey referenced by any ping's DEEPEST relay path + // in ONE query, then build each pending reply's "via RepeaterA → + // RepeaterB" text plus its observer-breadth label. Names default to + // the raw pubkey/prefix when unresolved rather than being dropped, so + // the hop count and reply still make sense. + if len(pendingPings) > 0 { + pubkeySet := map[string]bool{} + for _, p := range pendingPings { + for _, pk := range p.resolvedPath { + if pk != nil && *pk != "" { + pubkeySet[*pk] = true + } + } + } + pubkeys := make([]string, 0, len(pubkeySet)) + for pk := range pubkeySet { + pubkeys = append(pubkeys, pk) + } + names, _ := db.namesAndRolesForPubkeys(pubkeys) + + for txID, p := range pendingPings { + var repeaterNames []string + for _, pk := range p.resolvedPath { + if pk == nil || *pk == "" { + continue + } + if name := names[*pk]; name != "" { + repeaterNames = append(repeaterNames, name) + } else { + repeaterNames = append(repeaterNames, *pk) + } + } + // Breadth: name the single observer when there's only one (as + // specific as before), otherwise report the count -- "heard by + // 4 observers" says more about actual reach than an arbitrarily + // picked single name once more than one observer heard it. + observerLabel := "" + switch len(p.observers) { + case 0: + // leave empty + case 1: + for name := range p.observers { + observerLabel = name + } + default: + observerLabel = fmt.Sprintf("%d observers", len(p.observers)) + } + if m, ok := msgMap[txID]; ok { + m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, p.scope, repeaterNames) + } + } + } + // Issue #1366 follow-up: emit batch sorted by LatestSeen ascending // (newest LAST) — matches the in-memory path's tail-of-msgOrder // convention and the frontend's scrollToBottom() behavior. pageIDs diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 20af417d..0ef405b9 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "os" "path/filepath" + "strings" "testing" "time" @@ -620,6 +621,89 @@ func TestGetTraces(t *testing.T) { } } +// TestGetPacketPath covers the "View path" map data source: given a +// packet hash, resolve its DEEPEST observation's relay path to +// name/role/lat/lon per hop, plus the hearing observer's IATA-derived +// position. Deliberately independent of seedTestData's fixtures. +func TestGetPacketPath(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`) + // pkBravo deliberately has NO nodes row -- exercises the raw-pubkey/no-position fallback. + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + // Shallow observation (obs1): 1 hop. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`) + // Deeper observation (obs2): 2 hops -- must win even though it's not first. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`) + + resp, err := db.GetPacketPath("pathtest00000001") + if err != nil { + t.Fatal(err) + } + if resp.Hops != 2 { + t.Fatalf("Hops = %d, want 2 (the deeper observation)", resp.Hops) + } + if len(resp.Points) != 2 { + t.Fatalf("Points = %+v, want 2 entries", resp.Points) + } + if resp.Points[0].Name != "RepeaterAlpha" || resp.Points[0].Lat == nil || *resp.Points[0].Lat != 56.1 { + t.Errorf("Points[0] = %+v, want RepeaterAlpha at lat 56.1", resp.Points[0]) + } + if resp.Points[1].PublicKey != "pkBravo" || resp.Points[1].Name != "pkBravo" || resp.Points[1].Lat != nil { + t.Errorf("Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", resp.Points[1]) + } + if resp.Observer == nil || resp.Observer.Name != "Observer Two" { + t.Fatalf("Observer = %+v, want Observer Two (heard the deeper observation)", resp.Observer) + } + if resp.Observer.Lat == nil || *resp.Observer.Lat != 37.6213 { + t.Errorf("Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", resp.Observer.Lat) + } +} + +func TestGetPacketPath_NoResolvedPath(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'pathtest00000002', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', 1736935200)`) + + resp, err := db.GetPacketPath("pathtest00000002") + if err != nil { + t.Fatal(err) + } + if len(resp.Points) != 0 { + t.Errorf("Points = %+v, want empty when no observation has a resolved_path", resp.Points) + } + if resp.Observer != nil { + t.Errorf("Observer = %+v, want nil when there's no resolved path", resp.Observer) + } +} + +func TestGetPacketPath_UnknownHash(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + resp, err := db.GetPacketPath("doesnotexist0000") + if err != nil { + t.Fatal(err) + } + if resp.Hops != 0 || len(resp.Points) != 0 { + t.Errorf("expected an empty response for an unknown hash, got %+v", resp) + } +} + func TestGetChannels(t *testing.T) { db := setupTestDB(t) defer db.Close() @@ -1400,6 +1484,198 @@ func TestGetChannelMessagesNoSender(t *testing.T) { } } +// TestGetChannelMessages_PingBotReply covers the CoreScope-only "ping" +// bot: a channel message whose text is exactly "ping" gets a synthetic +// botReply attached (never transmitted back onto the mesh -- see +// pingBotReply's doc comment), while ordinary messages don't. +func TestGetChannelMessages_PingBotReply(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`) + // pkBravoRepeater deliberately has NO nodes row -- exercises the + // unresolved-pubkey fallback (raw pubkey shown instead of a name). + + // tx1: a plain chat message -- must NOT get a botReply. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"just chatting","sender":"Alice"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 9.0, -88, '["aa","bb"]', 1736935200)`) + + // tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer, + // and the relay path resolved to "RepeaterAlpha → pkBravoRepeater" + // (second hop has no nodes row, so its raw pubkey is shown instead). + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Bob"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (2, 1, 8.2, -90, '["aa","bb"]', '["pkAlphaRepeater","pkBravoRepeater"]', 1736935260)`) + + // tx3: "@CoreScopeBot ping" -- the mention-prefix must be stripped before matching. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"@CoreScopeBot ping","sender":"Carol"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 5.0, -95, '[]', 1736935320)`) + + // tx4: "pinging" -- must NOT match (not an exact "ping"). + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('DD', 'chanmsg00000004', '2026-01-15T10:03:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"pinging around","sender":"Dave"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (4, 1, 3.0, -99, '[]', 1736935380)`) + + // tx5: "/ping" -- the slash-command form must trigger too. + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"/ping","sender":"Frank"}', '#ping')`) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (5, 1, 6.0, -91, '["aa"]', 1736935440)`) + + messages, total, err := db.GetChannelMessages("#ping", 100, 0) + if err != nil { + t.Fatal(err) + } + if total != 5 { + t.Fatalf("expected 5 messages, got %d", total) + } + + byText := map[string]map[string]interface{}{} + for _, m := range messages { + byText[m["text"].(string)] = m + } + + if r := byText["just chatting"]["botReply"]; r != nil { + t.Errorf("plain chat message should not get a botReply, got %+v", r) + } + if r := byText["pinging around"]["botReply"]; r != nil { + t.Errorf("\"pinging\" should not match the exact \"ping\" trigger, got %+v", r) + } + + pingReply, _ := byText["ping"]["botReply"].(map[string]interface{}) + if pingReply == nil { + t.Fatal("bare \"ping\" message should get a botReply") + } + if pingReply["sender"] != "CoreScopeBot" { + t.Errorf("botReply sender = %v, want CoreScopeBot", pingReply["sender"]) + } + if pingReply["hops"] != 2 { + t.Errorf("botReply hops = %v, want 2", pingReply["hops"]) + } + replyText, _ := pingReply["text"].(string) + if !strings.Contains(replyText, "2 hops") || !strings.Contains(replyText, "8.2dB") || !strings.Contains(replyText, "Observer One") { + t.Errorf("botReply text = %q, want hops/SNR/observer mentioned", replyText) + } + if !strings.Contains(replyText, "via RepeaterAlpha → pkBravoRepeater") { + t.Errorf("botReply text = %q, want the resolved relay path (RepeaterAlpha for the known node, raw pubkey fallback for the unresolved one)", replyText) + } + + mentionReply, _ := byText["@CoreScopeBot ping"]["botReply"].(map[string]interface{}) + if mentionReply == nil { + t.Fatal("\"@CoreScopeBot ping\" should get a botReply (mention prefix stripped before matching)") + } + if mentionReply["hops"] != 0 { + t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"]) + } + + slashReply, _ := byText["/ping"]["botReply"].(map[string]interface{}) + if slashReply == nil { + t.Fatal("\"/ping\" should get a botReply -- it's in pingTriggerWords alongside bare \"ping\"") + } + if slashReply["sender"] != "CoreScopeBot" { + t.Errorf("\"/ping\" botReply sender = %v, want CoreScopeBot", slashReply["sender"]) + } +} + +// TestGetChannelMessages_PingBotReply_MultiObservation covers a single +// ping transmission heard by TWO different observers at +// DIFFERENT hop depths (normal in a mesh: one station may hear an early +// relay leg, another a later one). The botReply must report the DEEPEST +// (max-hop) observation's path/SNR -- not whichever observation happened +// to be scanned first -- and the breadth ("N observers") once more than +// one distinct station heard it, per pingBotReply's doc comment. +func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`) + db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`) + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkCharlieRepeater', 'RepeaterCharlie', 'repeater')`) + + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5, + '{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`) + // obs1 (scanned first, o.id=1): shallow leg, 1 hop. transmission_id=1 + // since this is the first (only) transmission inserted in this fresh DB. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlphaRepeater"]', 1736935440)`) + // obs2 (scanned second, o.id=2): deeper leg, 3 hops -- must win despite + // being neither first nor having the highest SNR. + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp) + VALUES (1, 2, 4.5, -99, '["aa","bb","cc"]', '["pkAlphaRepeater","pkBravoRepeater","pkCharlieRepeater"]', 1736935445)`) + + messages, _, err := db.GetChannelMessages("#ping", 100, 0) + if err != nil { + t.Fatal(err) + } + var reply map[string]interface{} + for _, m := range messages { + if m["text"] == "ping" { + reply, _ = m["botReply"].(map[string]interface{}) + } + } + if reply == nil { + t.Fatal("expected a botReply on the ping message") + } + if reply["hops"] != 3 { + t.Errorf("botReply hops = %v, want 3 (the deeper of the two observations)", reply["hops"]) + } + text, _ := reply["text"].(string) + if !strings.Contains(text, "SNR 4.5dB") { + t.Errorf("botReply text = %q, want the SNR paired with the deeper (3-hop) observation, not the shallower one's 9.0dB", text) + } + if !strings.Contains(text, "via RepeaterAlpha → pkBravoRepeater → RepeaterCharlie") { + t.Errorf("botReply text = %q, want the deeper observation's resolved relay path", text) + } + if !strings.Contains(text, "heard by 2 observers") { + t.Errorf("botReply text = %q, want breadth reported as \"2 observers\" now that more than one observer heard it", text) + } +} + +// TestAppendAreaToBotReply covers appendAreaToBotReply (routes.go): the +// handler-level pass that folds a ping message's resolved "area" (set by +// annotateMessageAreas, which needs server config unavailable to db.go) +// into its already-built botReply text. +func TestAppendAreaToBotReply(t *testing.T) { + withArea := map[string]interface{}{ + "area": "Aarhus", + "botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 2 hops"}, + } + noArea := map[string]interface{}{ + "botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 0 hops (direct)"}, + } + noBotReply := map[string]interface{}{"area": "Aarhus", "text": "just chatting"} + + appendAreaToBotReply([]map[string]interface{}{withArea, noArea, noBotReply}) + + gotText := withArea["botReply"].(map[string]interface{})["text"].(string) + if !strings.Contains(gotText, "area Aarhus") { + t.Errorf("botReply text = %q, want area appended", gotText) + } + + gotNoAreaText := noArea["botReply"].(map[string]interface{})["text"].(string) + if strings.Contains(gotNoAreaText, "area") { + t.Errorf("botReply text = %q, want unchanged when message has no area", gotNoAreaText) + } + + if _, ok := noBotReply["botReply"]; ok { + t.Error("a message with no botReply must not gain one") + } +} + func TestGetNetworkStatusDateFormats(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b2650d36..39e832b4 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -139,8 +139,10 @@ func routeDescriptions() map[string]routeMeta { "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"}, // Misc - "GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}}, - "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, + "GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}}, + "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, + "GET /api/packets/{hash}/path": {Summary: "Get a packet's geographic relay path", Description: "Resolves a packet's DEEPEST observation (the one with the most hops -- same reasoning as the ping-bot reply, issue tracker: when the same flood is heard by more than one station, the farthest-along leg is the more informative one to show) to a point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets", + Response: schemaRef("PacketPathResponse")}, "GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"}, "GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"}, } @@ -331,6 +333,36 @@ func componentSchemas() map[string]interface{} { "timeSeries": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthTimePoint"), "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse."}, }, }, + "PacketPathPoint": map[string]interface{}{ + "type": "object", + "description": "One hop's position along a packet's resolved relay path.", + "properties": map[string]interface{}{ + "publicKey": str("Node public key (hex)."), + "name": str("Node display name, or its public key if unnamed."), + "role": str("Node role (e.g. repeater, room), when known."), + "lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position."}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + }, + }, + "PacketPathObserver": map[string]interface{}{ + "type": "object", + "description": "The station that produced the deepest observation of a packet path, positioned from its configured IATA code.", + "properties": map[string]interface{}{ + "name": str("Observer display name."), + "iata": str("Observer's configured IATA airport code, when set."), + "lat": map[string]interface{}{"type": "number", "nullable": true}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + }, + }, + "PacketPathResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "hash": str("The packet hash this path was resolved for."), + "hops": map[string]interface{}{"type": "integer", "description": "Length of the deepest observed relay path."}, + "points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The relay path in hop order."}, + "observer": schemaRef("PacketPathObserver"), + }, + }, } } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 1879d914..a77e9808 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -346,6 +346,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET") r.HandleFunc("/api/observers", s.handleObservers).Methods("GET") r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET") + r.HandleFunc("/api/packets/{hash}/path", s.handlePacketPath).Methods("GET") r.HandleFunc("/api/paths/inspect", s.handlePathInspect).Methods("POST") r.HandleFunc("/api/iata-coords", s.handleIATACoords).Methods("GET") r.HandleFunc("/api/audio-lab/buckets", s.handleAudioLabBuckets).Methods("GET") @@ -2889,6 +2890,26 @@ func (s *Server) annotateMessageAreas(messages []map[string]interface{}) { } } +// appendAreaToBotReply folds a ping message's own resolved area (set by +// annotateMessageAreas just above, which MUST run first) into its +// botReply text. Area resolution needs server-level config (s.cfg.Areas) +// that db.go's GetChannelMessages/pingBotReply don't have access to, so +// this runs as a handler-level second pass instead. +func appendAreaToBotReply(messages []map[string]interface{}) { + for _, m := range messages { + area, _ := m["area"].(string) + if area == "" { + continue + } + reply, ok := m["botReply"].(map[string]interface{}) + if !ok { + continue + } + text, _ := reply["text"].(string) + reply["text"] = text + " · area " + area + } +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -2934,12 +2955,14 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { return } s.annotateMessageAreas(messages) + appendAreaToBotReply(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } if s.store != nil { messages, total := s.store.GetChannelMessages(hash, limit, offset, region) s.annotateMessageAreas(messages) + appendAreaToBotReply(messages) writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total}) return } @@ -3152,6 +3175,20 @@ func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) { writeJSON(w, TraceResponse{Traces: traces}) } +func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { + hash := mux.Vars(r)["hash"] + if s.db == nil { + writeJSON(w, PacketPathResponse{Hash: hash, Points: []PacketPathPoint{}}) + return + } + resp, err := s.db.GetPacketPath(hash) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, resp) +} + var iataCoords = map[string]IataCoord{ "SJC": {Lat: 37.3626, Lon: -121.929}, "SFO": {Lat: 37.6213, Lon: -122.379}, diff --git a/public/channels.js b/public/channels.js index 2dea7662..9b62d70a 100644 --- a/public/channels.js +++ b/public/channels.js @@ -323,6 +323,31 @@ return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash; } function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; } + // Mirrors pingBotReply in cmd/server/db.go -- kept in sync by hand since + // this is the client-side equivalent for messages that arrive live over + // the WebSocket (handleWSMessage below), which never round-trips through + // GetChannelMessages and so never gets the server-computed botReply. + // Same trigger rule, same reply format. CoreScope-only: see the doc + // comment on botReplyHtml in renderMessages for why this never reaches + // the real mesh. + // + // Unlike the server version, this one can't show the resolved relay + // path (repeater names) -- the live WS broadcast doesn't carry a + // per-packet resolved_path, only REST-loaded history does (via + // GetChannelMessages). scope/area ARE available live and are included. + // pingTriggerWords mirrors pingTriggerWords in cmd/server/db.go -- keep + // both lists in sync by hand. + var pingTriggerWords = { 'ping': true, '/ping': true }; + function pingBotReply(text, hops, snr, observer, scope, area) { + var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); + if (!pingTriggerWords[trigger.toLowerCase()]) return null; + var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)']; + if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB'); + if (observer) parts.push('heard by ' + observer); + if (scope) parts.push('scope ' + scope); + if (area) parts.push('area ' + area); + return { sender: 'CoreScopeBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr }; + } function getSenderColor(name) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); @@ -656,6 +681,7 @@ if (ci > 0 && ci < 50 && text.substring(0, ci) === sender) { text = text.substring(ci + 2); } + var alreadyDecObserver = c.packet.observer_name || null; decrypted.push({ sender: sender, text: text, timestamp: c.packet.first_seen || c.packet.timestamp, @@ -665,7 +691,8 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, - repeats: 1 + repeats: 1, + botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver, c.packet.scope_name || null) }); continue; } @@ -674,6 +701,7 @@ var result = await ChannelDecrypt.decryptPacket(keyBytes, c.decoded.mac, c.decoded.encryptedData); if (result) { macFailCount = 0; + var decObserver = c.packet.observer_name || null; decrypted.push({ sender: result.sender, text: result.message, timestamp: c.packet.first_seen || c.packet.timestamp, @@ -683,7 +711,8 @@ observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, - repeats: 1 + repeats: 1, + botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver, c.packet.scope_name || null) }); } else { macFailCount++; @@ -1323,6 +1352,10 @@ }); msgEl.addEventListener('click', handleNodeTap); + msgEl.addEventListener('click', function (e) { + const el = e.target.closest('[data-view-path]'); + if (el && window.PacketPathMap) window.PacketPathMap.open(el.dataset.viewPath); + }); // touchend fires more reliably on mobile for non-button elements let touchMoved = false; msgEl.addEventListener('touchstart', () => { touchMoved = false; }, { passive: true }); @@ -1467,6 +1500,7 @@ existing._fromWS = true; existing._wsAt = Date.now(); } else { + var wsHops = payload.path_len || 0; messages.push({ sender: sender, text: displayText, @@ -1476,11 +1510,12 @@ packetHash: pktHash, repeats: 1, observers: observer ? [observer] : [], - hops: payload.path_len || 0, + hops: wsHops, snr: snr, scope: scope, routeType: routeType, area: area, + botReply: pingBotReply(displayText, wsHops, snr, observer, scope, area), // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of // stomp. Without this flag the REST response wipes any @@ -2290,6 +2325,30 @@ if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`); const safeId = btoa(encodeURIComponent(sender)); + + // Ping-bot reply (server-synthesized in GetChannelMessages when this + // message's text matches a trigger word (pingTriggerWords) -- see + // pingBotReply in db.go). + // CoreScope-only: never transmitted back onto the mesh, since + // CoreScope has no publish path to a MeshCore broker/radio. The + // "Not sent to the mesh" caveat is load-bearing, not decoration -- + // without it this could be misread as a real bot reply the sender's + // own radio received. + // "View path" only makes sense when there's an actual multi-hop + // route to draw (hops > 0) and we have a packet hash to look it up + // by -- a direct (0-hop) reply has no relay path to show on a map. + const viewPathHtml = (msg.botReply && msg.botReply.hops > 0 && msg.packetHash) + ? ` · ` + : ''; + const botReplyHtml = msg.botReply ? `
+ +
+
${escapeHtml(msg.botReply.sender || 'CoreScopeBot')}
+
${escapeHtml(msg.botReply.text || '')}
+
Not sent to the mesh — CoreScope-only reply${viewPathHtml}
+
+
` : ''; + // #1367: emit BOTH the new chat-app class names (.ch-message / // .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg* // names so existing tests/themes don't regress. @@ -2300,7 +2359,7 @@
${displayText}
${meta.join(' · ')}${msg.packetHash ? ` · View packet →` : ''}
- `; + ${botReplyHtml}`; }).join(''); } @@ -2309,6 +2368,8 @@ if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); } } + window._channelsRenderMessagesForTest = renderMessages; + window._channelsPingBotReplyForTest = pingBotReply; window._channelsSetStateForTest = function (state) { if (!state) return; if (Array.isArray(state.channels)) channels = state.channels; diff --git a/public/index.html b/public/index.html index aea7556b..8b337235 100644 --- a/public/index.html +++ b/public/index.html @@ -220,6 +220,7 @@ + diff --git a/public/packet-path-map.js b/public/packet-path-map.js new file mode 100644 index 00000000..f035e154 --- /dev/null +++ b/public/packet-path-map.js @@ -0,0 +1,131 @@ +/* window.PacketPathMap.open(hash) — on-demand modal showing a packet's + resolved relay path (see GET /api/packets/{hash}/path, cmd/server/db.go + GetPacketPath) as a sequential Leaflet map: each hop plotted in path + order and connected by a line, ending at the observer that produced + the deepest observation. Reuses node-reach-map.js's Leaflet setup + conventions (tile helper, circleMarker points, theme-aware colors) but + draws an ORDERED CHAIN instead of a star, since a relay path is a + sequence, not a hub-and-spoke. + + Entry point today: the ping-bot reply's "View path" link + (public/channels.js botReplyHtml) -- kept general (keyed by packet + hash, not ping-specific) since any packet with a resolved path could + use the same view later. */ +(function () { + 'use strict'; + + function cssVar(name) { + var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || '#888'; + } + + var activeMap = null; + + function onKeydown(e) { + if (e.key === 'Escape') close(); + } + + function close() { + var overlay = document.getElementById('packetPathModal'); + if (overlay) overlay.remove(); + if (activeMap) { + try { activeMap.remove(); } catch (e) { /* already gone */ } + activeMap = null; + } + document.removeEventListener('keydown', onKeydown); + } + + async function open(hash) { + close(); // in case one's already open + + var overlay = document.createElement('div'); + overlay.id = 'packetPathModal'; + overlay.className = 'modal-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); + var closeBtn = document.getElementById('packetPathClose'); + if (closeBtn) closeBtn.addEventListener('click', close); + document.addEventListener('keydown', onKeydown); + + var statusEl = document.getElementById('packetPathStatus'); + + var data; + try { + data = await api('/packets/' + encodeURIComponent(hash) + '/path'); + } catch (e) { + if (statusEl) statusEl.textContent = 'Failed to load path: ' + e.message; + return; + } + + var allHops = data.points || []; + var located = allHops.filter(function (p) { return p.lat != null && p.lon != null; }); + var missing = allHops.length - located.length; + var hasObserver = !!(data.observer && data.observer.lat != null && data.observer.lon != null); + + if (located.length === 0 && !hasObserver) { + if (statusEl) { + statusEl.textContent = data.hops > 0 + ? 'None of the ' + data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' in this path have a known position yet.' + : 'This packet has no resolved relay path yet.'; + } + return; + } + + if (typeof L === 'undefined') { + if (statusEl) statusEl.textContent = 'Map library unavailable.'; + return; + } + + var chain = located.map(function (p, i) { + return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (i + 1) + ' of ' + data.hops }; + }); + if (hasObserver) { + chain.push({ lat: data.observer.lat, lon: data.observer.lon, name: data.observer.name, label: 'observer', isObserver: true }); + } + + var center = chain[Math.floor(chain.length / 2)]; + var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false }) + .setView([center.lat, center.lon], 10); + if (typeof window._applyTilesToNodeMap === 'function') { + window._applyTilesToNodeMap(map); + } else { + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); + } + + var outline = cssVar('--surface-0'); + var accent = cssVar('--accent'); + var observerColor = cssVar('--status-yellow'); + + var bounds = []; + var line = []; + chain.forEach(function (p) { + bounds.push([p.lat, p.lon]); + line.push([p.lat, p.lon]); + var color = p.isObserver ? observerColor : accent; + L.circleMarker([p.lat, p.lon], { radius: p.isObserver ? 7 : 6, color: outline, weight: 2, fillColor: color, fillOpacity: 1 }) + .addTo(map) + .bindTooltip(escapeHtml(p.name) + ' (' + p.label + ')'); + }); + if (line.length > 1) { + L.polyline(line, { color: accent, weight: 2.5, opacity: 0.85 }).addTo(map); + } + try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ } + setTimeout(function () { map.invalidateSize(); }, 120); + activeMap = map; + + var statusParts = [data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' total']; + if (missing > 0) statusParts.push(missing + ' without a known position (not shown)'); + if (statusEl) statusEl.textContent = statusParts.join(' · '); + } + + window.PacketPathMap = { open: open, close: close }; +})(); diff --git a/public/style.css b/public/style.css index 3d7c3d3b..59bcbd20 100644 --- a/public/style.css +++ b/public/style.css @@ -1902,6 +1902,11 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } border: 1px solid var(--border); } .ch-mention { color: var(--link-color); font-weight: 600; } +/* Ping-bot reply (public/channels.js botReplyHtml) -- dashed border marks + * it as a synthesized, CoreScope-only reply, never actually transmitted + * onto the mesh, distinct from a real observed message. */ +.ch-bot-message { margin-top: -8px; } +.ch-bot-message .ch-msg-bubble { border-style: dashed; font-style: italic; } .ch-encrypted-text { font-size: 11px; color: var(--text-muted); } .ch-msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; } .ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; } diff --git a/test-all.sh b/test-all.sh index bb19215c..cf16694e 100755 --- a/test-all.sh +++ b/test-all.sh @@ -72,6 +72,8 @@ node test-issue-1770-mobile-row-clamp.js node test-issue-1849-trace-hashbytes.js node test-node-analytics-hop-chart.js node test-analytics-hop-depth-ui.js +node test-channels-ping-bot-reply.js +node test-packet-path-map.js echo "" echo "═══════════════════════════════════════" diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js new file mode 100644 index 00000000..30206d26 --- /dev/null +++ b/test-channels-ping-bot-reply.js @@ -0,0 +1,248 @@ +/** + * Unit tests for the CoreScope-only "ping" bot reply bubble + * (public/channels.js renderMessages' botReplyHtml block). + * + * The backend (cmd/server/db.go pingBotReply) attaches a synthetic + * `botReply` field to a channel message whose text is exactly "ping" -- + * this file covers that the frontend renders it distinctly, includes the + * "not sent to the mesh" caveat, and escapes attacker-controlled fields + * (observer names flow into botReply.text server-side, so it must not be + * trusted blindly). + * + * Sandbox pattern borrowed from test-channels-merge-1498-unit.js: load + * channels.js in a tolerant vm context, grab the test-only export. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function makeSandbox() { + const noop = () => {}; + const fakeEl = () => ({ + addEventListener: noop, querySelector: () => null, querySelectorAll: () => [], + classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null, + textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0, + }); + const chMessagesEl = fakeEl(); + const doc = { + readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(), + documentElement: fakeEl(), + getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null), + querySelector: () => null, querySelectorAll: () => [], + addEventListener: noop, + }; + const win = { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) }; + const ctx = { + window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error, + setTimeout, clearTimeout, setInterval, clearInterval, + history: { replaceState: noop, pushState: noop }, + location: { hash: '', href: '', pathname: '/' }, + navigator: { userAgent: 'node' }, + RegionFilter: { getRegionParam: () => '' }, + api: () => Promise.resolve({ messages: [] }), + CLIENT_TTL: {}, + ChannelDecrypt: undefined, + truncate: (s) => s, + formatHashHex: (h) => String(h), + channelDisplayName: (c) => c && c.name, + escapeHtml, + getSenderColor: () => '#123456', + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'), + }; + vm.createContext(ctx); + try { + vm.runInContext(fs.readFileSync('public/channels.js', 'utf8'), ctx); + } catch (e) { + // Tolerant: only the render path under test needs to have been + // exported before any unrelated init code throws. + } + return { ctx, chMessagesEl }; +} + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +console.log('\n=== channels.js: pingBotReply (shared trigger/format logic) ==='); +// This is the client-side twin of pingBotReply in cmd/server/db.go, used +// by the WebSocket live-push path and the client-side PSK-channel decrypt +// path (neither of which round-trips through GetChannelMessages, so +// neither gets the server-computed botReply without this). + +test('exact "ping" (any case) triggers a reply', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('ping', 1, 5, 'Obs') !== null); + assert.ok(fn('PING', 1, 5, 'Obs') !== null); + assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed'); +}); + +test('"/ping" (the slash-command form) also triggers, alongside bare "ping"', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('/ping', 1, 5, 'Obs') !== null); + assert.ok(fn('/PING', 1, 5, 'Obs') !== null, 'case-insensitive like the bare form'); + assert.strictEqual(fn('/pingx', 1, 5, 'Obs'), null, 'still an exact match, not a prefix match'); +}); + +test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.ok(fn('@CoreScopeBot ping', 0, null, null) !== null); +}); + +test('"pinging" or other substrings do not match (exact trigger only)', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + assert.strictEqual(fn('pinging around', 1, 5, 'Obs'), null); + assert.strictEqual(fn('not ping', 1, 5, 'Obs'), null); + assert.strictEqual(fn('', 1, 5, 'Obs'), null); +}); + +test('reply text includes hops, SNR, and observer when present', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const r = fn('ping', 3, 8.25, 'Observer One'); + assert.strictEqual(r.sender, 'CoreScopeBot'); + assert.ok(r.text.includes('3 hops'), r.text); + assert.ok(r.text.includes('SNR 8.3dB') || r.text.includes('SNR 8.2dB'), r.text); + assert.ok(r.text.includes('heard by Observer One'), r.text); +}); + +test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const r = fn('ping', 0, null, null); + assert.ok(r.text.includes('0 hops (direct)'), r.text); + assert.ok(!r.text.includes('SNR'), r.text); + assert.ok(!r.text.includes('heard by'), r.text); +}); + +test('scope and area are included when present, omitted when not', () => { + const { ctx } = makeSandbox(); + const fn = ctx.window._channelsPingBotReplyForTest; + const withBoth = fn('ping', 1, 5, 'Obs', '#dk', 'Aarhus'); + assert.ok(withBoth.text.includes('scope #dk'), withBoth.text); + assert.ok(withBoth.text.includes('area Aarhus'), withBoth.text); + + const withNeither = fn('ping', 1, 5, 'Obs', null, null); + assert.ok(!withNeither.text.includes('scope'), withNeither.text); + assert.ok(!withNeither.text.includes('area'), withNeither.text); +}); + +console.log('\n=== channels.js: ping-bot reply rendering ==='); + +test('a message without botReply renders no bot bubble', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Alice', text: 'just chatting', timestamp: '2026-01-15T10:00:00Z' }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('ch-bot-message'), 'no botReply field should mean no bot bubble'); +}); + +test('a message with botReply renders a distinct bot bubble with the reply text', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(html.includes('ch-bot-message'), 'should render the distinct bot-message class'); + assert.ok(html.includes('CoreScopeBot'), 'should show the bot sender name'); + assert.ok(html.includes('2 hops'), 'should include the hop count from the reply text'); + assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text'); +}); + +test('"View path" link appears when hops > 0 and a packetHash is available', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(html.includes('View path'), 'should show the View path link'); + assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up'); +}); + +test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 0 hops (direct)', hops: 0, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'a direct reply has no relay path to visualize'); +}); + +test('"View path" link is absent when there is no packetHash to look it up by', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'without a packetHash there is nothing to fetch the path for'); +}); + +test('the "not sent to the mesh" caveat is always present on a bot bubble', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + assert.ok(chMessagesEl.innerHTML.includes('Not sent to the mesh'), 'the caveat must be visible so this is never mistaken for a real mesh reply'); +}); + +test('botReply.text and .sender are HTML-escaped (observer names are operator-controlled)', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { + sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', + botReply: { sender: '', text: 'heard by ', hops: 0 }, + }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + assert.ok(!html.includes('alert(2)'), 'botReply.text must be escaped'); +}); + +test('the bot bubble renders immediately after its triggering message, not before other messages', () => { + const { ctx, chMessagesEl } = makeSandbox(); + ctx.window._channelsSetStateForTest({ messages: [ + { sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } }, + { sender: 'Carol', text: 'after', timestamp: '2026-01-15T10:02:00Z' }, + ] }); + ctx.window._channelsRenderMessagesForTest(); + const html = chMessagesEl.innerHTML; + const pingIdx = html.indexOf('>ping<'); + const botIdx = html.indexOf('ch-bot-message'); + const afterIdx = html.indexOf('>after<'); + assert.ok(pingIdx > -1 && botIdx > -1 && afterIdx > -1, 'all three pieces should be present'); + assert.ok(pingIdx < botIdx && botIdx < afterIdx, 'order should be: ping message, bot reply, next message'); +}); + +console.log('\n════════════════════════════════════════'); +console.log(` Channels ping-bot reply: ${passed} passed, ${failed} failed`); +console.log('════════════════════════════════════════'); +if (failed > 0) process.exit(1); diff --git a/test-packet-path-map.js b/test-packet-path-map.js new file mode 100644 index 00000000..1f63ef3a --- /dev/null +++ b/test-packet-path-map.js @@ -0,0 +1,179 @@ +/** + * Tests for public/packet-path-map.js — the on-demand "View path" modal + * that draws a packet's resolved relay path on a Leaflet map (backed by + * GET /api/packets/{hash}/path, cmd/server/db.go GetPacketPath). + * + * Two layers, matching this repo's established pattern for modal/DOM + * code (see test-channel-modal-ux.js): string-contract checks over the + * raw source for structural/safety properties, plus a functional smoke + * test using a minimal-but-real DOM mock (createElement/appendChild/ + * getElementById/remove all actually work, unlike the channels.js test + * sandbox's inert stubs) to exercise open()/close() end-to-end on the + * two code paths that don't need Leaflet: a failed fetch, and a + * fetch that resolves with nothing plottable. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +const src = fs.readFileSync('public/packet-path-map.js', 'utf8'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +console.log('\n=== packet-path-map.js: string-contract checks ==='); + +test('exports window.PacketPathMap.{open,close}', () => { + assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close\s*\}/.test(src)); +}); + +test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-URL handling)', () => { + assert.ok(/api\(\s*'\/packets\/'\s*\+\s*encodeURIComponent\(hash\)\s*\+\s*'\/path'\s*\)/.test(src)); +}); + +test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => { + assert.ok(/escapeHtml\(p\.name\)/.test(src), 'point tooltips must escape the name'); +}); + +test('handles Escape key and click-outside to close, matching other CoreScope modals', () => { + assert.ok(/e\.key === 'Escape'/.test(src)); + assert.ok(/e\.target === overlay/.test(src)); +}); + +test('degrades gracefully when the Leaflet global is unavailable, rather than throwing', () => { + assert.ok(/typeof L === 'undefined'/.test(src)); +}); + +test('close() tears down the Leaflet map instance, not just the DOM overlay (avoids a leaked map on repeat opens)', () => { + assert.ok(/activeMap\.remove\(\)/.test(src)); +}); + +console.log('\n=== packet-path-map.js: functional smoke test (no-Leaflet code paths) ==='); + +function makeSandbox(apiImpl) { + // A minimal but REAL DOM: elements track their own children/attributes + // so createElement -> appendChild -> getElementById -> remove() all + // actually work, unlike the inert stubs used for pure string-render + // testing elsewhere. Deliberately small: only what open()/close() touch. + function makeElement(tag) { + const el = { + tagName: tag, children: [], attributes: {}, style: {}, dataset: {}, + _listeners: {}, + get id() { return this.attributes.id || ''; }, + set id(v) { this.attributes.id = v; }, + // Real innerHTML would parse into a live child tree; this mock only + // needs id-addressable children with a settable textContent (all + // open()/close() read back), so it scans for id="..." occurrences + // and registers one lightweight child per id found. + set innerHTML(html) { + this._innerHTML = html; + this.children = []; + const re = /id="([^"]+)"/g; + let m; + while ((m = re.exec(html))) { + const child = makeElement('div'); + child.id = m[1]; + this.appendChild(child); + } + }, + get innerHTML() { return this._innerHTML || ''; }, + set textContent(t) { this._text = t; }, + get textContent() { return this._text || ''; }, + appendChild(child) { this.children.push(child); child._parent = this; return child; }, + remove() { if (this._parent) this._parent.children = this._parent.children.filter(c => c !== this); }, + addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }, + removeEventListener(type, fn) { if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(f => f !== fn); }, + querySelector() { return null; }, + }; + return el; + } + + const body = makeElement('body'); + const docListeners = {}; + const doc = { + createElement: makeElement, + body, + documentElement: { style: {} }, + getElementById(id) { + const search = (el) => { + if (el.id === id) return el; + for (const c of el.children) { const found = search(c); if (found) return found; } + return null; + }; + return search(body); + }, + addEventListener(type, fn) { (docListeners[type] = docListeners[type] || []).push(fn); }, + removeEventListener(type, fn) { if (docListeners[type]) docListeners[type] = docListeners[type].filter(f => f !== fn); }, + }; + + const ctx = { + window: {}, document: doc, console, Math, String, JSON, Promise, Error, + setTimeout, clearTimeout, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'), + api: apiImpl, + L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths. + }; + vm.createContext(ctx); + vm.runInContext(src, ctx); + return ctx; +} + +(async () => { + await (async () => { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('network down'))); + await ctx.window.PacketPathMap.open('deadbeef'); + const overlay = ctx.document.getElementById('packetPathModal'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(overlay, 'modal overlay should be created'); + assert.ok(status.textContent.includes('Failed to load path'), 'should surface the fetch error, got: ' + status.textContent); + passed++; + console.log(' ✅ a failed fetch shows an error status without throwing'); + } catch (e) { failed++; console.log(' ❌ a failed fetch shows an error status without throwing: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 0, points: [] })); + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('no resolved relay path'), 'should explain there is nothing to show yet, got: ' + status.textContent); + passed++; + console.log(' ✅ an empty path (hops=0, no points) shows a clear "nothing to show" status'); + } catch (e) { failed++; console.log(' ❌ an empty path (hops=0, no points) shows a clear "nothing to show" status: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }] })); + await ctx.window.PacketPathMap.open('deadbeef'); + const status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no hop has a known position, got: ' + status.textContent); + passed++; + console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank'); + } catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); } + })(); + + await (async () => { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('boom'))); + await ctx.window.PacketPathMap.open('deadbeef'); + assert.ok(ctx.document.getElementById('packetPathModal'), 'modal should be open'); + ctx.window.PacketPathMap.close(); + assert.ok(!ctx.document.getElementById('packetPathModal'), 'modal should be removed after close()'); + passed++; + console.log(' ✅ close() removes the modal overlay from the DOM'); + } catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); } + })(); + + console.log('\n════════════════════════════════════════'); + console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + if (failed > 0) process.exit(1); +})();