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 ? `
` : ''; + // #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 @@ - `; + ${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 = + 'How far this packet traveled before reaching the farthest-along observer. Hops without a known GPS position are omitted from the line.
' + + '' + + '