From b3d7efb5115d3e60f631193238f41b0d1c9cbc5f Mon Sep 17 00:00:00 2001 From: dborup Date: Thu, 23 Jul 2026 14:39:13 +0200 Subject: [PATCH] feat: ping-bot reply shows relay path, scope, and area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST-loaded channel history (GetChannelMessages) now resolves the ping message's relay path to node names ("via RepeaterA โ†’ RepeaterB"), bulk- resolving every referenced pubkey across the page in one query rather than per-message. Falls back to the raw pubkey when a hop's node isn't known. Also includes the message's region scope and (via a handler-level pass, since area resolution needs server config db.go doesn't have) its resolved area. The client-side pingBotReply (WebSocket live-push + PSK-channel decrypt paths) gains scope/area too, since both are already present in that data. Relay-path names are REST-only for now: the live WS broadcast doesn't carry a resolved_path, only historical/REST-loaded messages do. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 119 +++++++++++++++++++++++++++----- cmd/server/db_test.go | 45 +++++++++++- cmd/server/routes.go | 22 ++++++ public/channels.js | 15 ++-- test-channels-ping-bot-reply.js | 12 ++++ 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 8999f165..50a05715 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1755,25 +1755,38 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, // a bare "ping". var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`) -// pingBotReply synthesizes a "pong" reply for a channel message whose -// (mention-stripped) text is exactly "ping" โ€” 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, SNR, hearing observer), -// not a persisted message: nil when text isn't a ping trigger. -func pingBotReply(displayText string, hops int, snr sql.NullFloat64, observer string) map[string]interface{} { +// isPingTrigger reports whether displayText, after stripping a leading +// "@target " mention the same way the frontend does (public/channels.js +// replyMatch), is exactly "ping". +func isPingTrigger(displayText string) bool { trigger := strings.TrimSpace(displayText) trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "") - if !strings.EqualFold(strings.TrimSpace(trigger), "ping") { - return nil - } - parts := make([]string, 0, 3) + return strings.EqualFold(strings.TrimSpace(trigger), "ping") +} + +// 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 = "" } - parts = append(parts, fmt.Sprintf("%d hop%s", hops, 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)") } @@ -1783,6 +1796,9 @@ func pingBotReply(displayText string, hops int, snr sql.NullFloat64, observer st if observer != "" { parts = append(parts, "heard by "+observer) } + if scope != "" { + parts = append(parts, "scope "+scope) + } return map[string]interface{}{ "sender": "MeshviewBot", "text": "๐Ÿ“ pong! " + strings.Join(parts, " ยท "), @@ -1912,10 +1928,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 @@ -1923,7 +1946,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, ",") + `) @@ -1943,9 +1966,24 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } msgMap := make(map[int]*msg, len(pageIDs)) + // pendingPing collects a ping-triggering message's ingredients for its + // botReply. Reply text isn't built inline in the scan loop below + // because the repeater-name lookup needs ONE bulk query across every + // ping on the page (see the pass after the loop) rather than a + // per-message round trip. + type pendingPing struct { + txID int + hops int + snr sql.NullFloat64 + observer string + scope string + resolvedPath []*string + } + var pendingPings []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 @@ -1954,6 +1992,9 @@ 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 } @@ -2028,12 +2069,56 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . observerName = obsID.String m.Data["observers"] = []string{obsID.String} } - if reply := pingBotReply(displayText, hops, snr, observerName); reply != nil { - m.Data["botReply"] = reply + if isPingTrigger(displayText) { + var resolvedPath []*string + if resolvedPathJSON.Valid { + resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String) + } + pendingPings = append(pendingPings, pendingPing{ + txID: txID, hops: hops, snr: snr, observer: observerName, + scope: scopeName.String, resolvedPath: resolvedPath, + }) } msgMap[txID] = m } + // Bulk-resolve every pubkey referenced by any ping's relay path in ONE + // query, then build each pending reply's "via RepeaterA โ†’ RepeaterB" + // text. 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 _, 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) + } + } + if m, ok := msgMap[p.txID]; ok { + m.Data["botReply"] = pingBotReply(p.hops, p.snr, p.observer, 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 28470608..d47a0233 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -1410,6 +1410,9 @@ func TestGetChannelMessages_PingBotReply(t *testing.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) @@ -1418,12 +1421,14 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { 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. + // 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, timestamp) - VALUES (2, 1, 8.2, -90, '["aa","bb"]', 1736935260)`) + 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: "@MeshviewBot 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) @@ -1473,6 +1478,9 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { 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["@MeshviewBot ping"]["botReply"].(map[string]interface{}) if mentionReply == nil { @@ -1483,6 +1491,37 @@ func TestGetChannelMessages_PingBotReply(t *testing.T) { } } +// 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": "MeshviewBot", "text": "๐Ÿ“ pong! 2 hops"}, + } + noArea := map[string]interface{}{ + "botReply": map[string]interface{}{"sender": "MeshviewBot", "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/routes.go b/cmd/server/routes.go index 1879d914..5f4c2560 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2889,6 +2889,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 +2954,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 } diff --git a/public/channels.js b/public/channels.js index b9bed4b6..5eaddb44 100644 --- a/public/channels.js +++ b/public/channels.js @@ -330,12 +330,19 @@ // Same trigger rule, same reply format. CoreScope-only: see the doc // comment on botReplyHtml in renderMessages for why this never reaches // the real mesh. - function pingBotReply(text, hops, snr, observer) { + // + // 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. + function pingBotReply(text, hops, snr, observer, scope, area) { var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim(); if (trigger.toLowerCase() !== 'ping') 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: 'MeshviewBot', text: '๐Ÿ“ pong! ' + parts.join(' ยท '), hops: hops, snr: snr }; } function getSenderColor(name) { @@ -682,7 +689,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, repeats: 1, - botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver) + botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver, c.packet.scope_name || null) }); continue; } @@ -702,7 +709,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, repeats: 1, - botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver) + botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver, c.packet.scope_name || null) }); } else { macFailCount++; @@ -1501,7 +1508,7 @@ scope: scope, routeType: routeType, area: area, - botReply: pingBotReply(displayText, wsHops, snr, observer), + 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 diff --git a/test-channels-ping-bot-reply.js b/test-channels-ping-bot-reply.js index 1d7d3601..1aae9ec4 100644 --- a/test-channels-ping-bot-reply.js +++ b/test-channels-ping-bot-reply.js @@ -119,6 +119,18 @@ test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly 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', () => {