mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 09:32:26 +00:00
feat: ping-bot reply shows relay path, scope, and area
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
79c734a26e
commit
b3d7efb511
5 files changed
+189
-24
No files matched your search
+102
-17
@@ -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
|
||||
|
||||
+42
-3
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+11
-4
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user